diff --git a/README.md b/README.md
index 817f3e22..291e42f6 100644
--- a/README.md
+++ b/README.md
@@ -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 |
@@ -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`)
@@ -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
@@ -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:
@@ -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:
diff --git a/docs/draft-development.md b/docs/draft-development.md
new file mode 100644
index 00000000..28878c3c
--- /dev/null
+++ b/docs/draft-development.md
@@ -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.
diff --git a/examples/broadcast/catalog-publisher.ts b/examples/broadcast/catalog-publisher.ts
index b91fa89c..20e2bc89 100644
--- a/examples/broadcast/catalog-publisher.ts
+++ b/examples/broadcast/catalog-publisher.ts
@@ -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';
@@ -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);
diff --git a/examples/broadcast/media-publisher.test.ts b/examples/broadcast/media-publisher.test.ts
index 626473e0..b55f1fc5 100644
--- a/examples/broadcast/media-publisher.test.ts
+++ b/examples/broadcast/media-publisher.test.ts
@@ -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);
@@ -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
@@ -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();
diff --git a/examples/broadcast/media-publisher.ts b/examples/broadcast/media-publisher.ts
index c16fbb1a..1f59a695 100644
--- a/examples/broadcast/media-publisher.ts
+++ b/examples/broadcast/media-publisher.ts
@@ -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 {
@@ -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;
@@ -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. */
@@ -296,7 +297,7 @@ export class MediaPublisher {
hasExtensions: true,
endOfGroup: true,
publisherPriority: priority,
- ...(this.draft === 18 ? { firstObject: true } : {}),
+ ...(isRequestStreamDraft(this.draft) ? { firstObject: true } : {}),
};
}
diff --git a/examples/catalog/index.html b/examples/catalog/index.html
index 5ed4e4af..b562c7b1 100644
--- a/examples/catalog/index.html
+++ b/examples/catalog/index.html
@@ -184,6 +184,7 @@
+
diff --git a/examples/catalog/main.ts b/examples/catalog/main.ts
index 4c43e1d9..cfe44a88 100644
--- a/examples/catalog/main.ts
+++ b/examples/catalog/main.ts
@@ -182,7 +182,7 @@ async function run(): Promise {
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) {
diff --git a/examples/node-publisher/fixtures/testsrc.mp4 b/examples/node-publisher/fixtures/testsrc.mp4
new file mode 100644
index 00000000..0cbf9781
Binary files /dev/null and b/examples/node-publisher/fixtures/testsrc.mp4 differ
diff --git a/examples/node-relay/src/relay.ts b/examples/node-relay/src/relay.ts
index e5fc509c..d77f2571 100644
--- a/examples/node-relay/src/relay.ts
+++ b/examples/node-relay/src/relay.ts
@@ -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);
@@ -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) };
diff --git a/examples/shared/cert.ts b/examples/shared/cert.ts
index c5dc1c21..084b34f5 100644
--- a/examples/shared/cert.ts
+++ b/examples/shared/cert.ts
@@ -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;
})();
diff --git a/examples/shared/probe-transport.ts b/examples/shared/probe-transport.ts
index 56bae11c..f43bb8df 100644
--- a/examples/shared/probe-transport.ts
+++ b/examples/shared/probe-transport.ts
@@ -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;
}
diff --git a/packages/browser/src/webtransport-factory.ts b/packages/browser/src/webtransport-factory.ts
index d81d9926..7505ead0 100644
--- a/packages/browser/src/webtransport-factory.ts
+++ b/packages/browser/src/webtransport-factory.ts
@@ -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)
@@ -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;
}
/**
diff --git a/packages/loc/src/headers.test.ts b/packages/loc/src/headers.test.ts
index 7b48da94..d336f492 100644
--- a/packages/loc/src/headers.test.ts
+++ b/packages/loc/src/headers.test.ts
@@ -19,6 +19,7 @@ import {
encodeLocHeaders,
toVideoChunkInit,
toAudioChunkInit,
+ locWireProfileForDraft,
} from './headers.js';
import { LocExtensionId } from './types.js';
import type { LocHeaders } from './types.js';
@@ -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);
diff --git a/packages/loc/src/headers.ts b/packages/loc/src/headers.ts
index cd5434a3..4461a98d 100644
--- a/packages/loc/src/headers.ts
+++ b/packages/loc/src/headers.ts
@@ -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';
diff --git a/packages/playa/src/types.ts b/packages/playa/src/types.ts
index fb1c051a..49526c2e 100644
--- a/packages/playa/src/types.ts
+++ b/packages/playa/src/types.ts
@@ -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[];
diff --git a/packages/player/src/catalog-bootstrap.test.ts b/packages/player/src/catalog-bootstrap.test.ts
index 0ce196d0..0383931e 100644
--- a/packages/player/src/catalog-bootstrap.test.ts
+++ b/packages/player/src/catalog-bootstrap.test.ts
@@ -56,7 +56,7 @@ interface Harness {
};
}
-function makeHarness(overrides?: { draft?: 14 | 16 | 18 }): Harness {
+function makeHarness(overrides?: { draft?: 14 | 16 | 18 | 21 }): Harness {
const manager = new CatalogManager('live/test');
const calls: Harness['calls'] = {
joiningFetch: 0, standaloneFetches: [], cancels: 0,
@@ -468,6 +468,45 @@ describe('CatalogBootstrap — failure ladder', () => {
});
});
+describe('CatalogBootstrap — draft-21 fill', () => {
+ // draft-21 §3.4: a fill range that starts after Largest Object opens no fill
+ // stream. With no Largest Object the track is empty: wait for the first
+ // live head, exactly like a Joining FETCH answered with INVALID_RANGE.
+ it('SUBSCRIBE_OK without a Largest Object resolves the fill attempt as an empty track', () => {
+ vi.useFakeTimers();
+ const h = makeHarness({ draft: 21 });
+ h.coord.start();
+ h.coord.onSubscribeOk(null);
+ expect(h.coord.phase).toBe('empty-wait');
+ vi.advanceTimersByTime(60_000);
+ expect(h.calls.legacyResubscribes).toBe(0);
+ expect(h.calls.standaloneFetches).toHaveLength(0);
+ h.coord.onLiveCatalogObject({ location: { group: 0n, object: 0n }, kind: 'payload', payload: msf01Indep(['video']) }, 110n);
+ expect(h.calls.ready).toEqual([['video']]);
+ });
+
+ it('the same holds when SUBSCRIBE_OK arrives before the coordinator starts', () => {
+ const h = makeHarness({ draft: 21 });
+ h.coord.onSubscribeOk(null);
+ h.coord.start();
+ expect(h.coord.phase).toBe('empty-wait');
+ });
+
+ it('with a Largest Object the fill is awaited', () => {
+ const h = makeHarness({ draft: 21 });
+ h.coord.start();
+ h.coord.onSubscribeOk({ group: 5n, object: 1n });
+ expect(h.coord.phase).toBe('fetching');
+ });
+
+ it('draft 18 keeps waiting for the Joining FETCH response', () => {
+ const h = makeHarness({ draft: 18 });
+ h.coord.start();
+ h.coord.onSubscribeOk(null);
+ expect(h.coord.phase).toBe('fetching');
+ });
+});
+
describe('CatalogBootstrap — PUBLISH_DONE reasons', () => {
it('#21: TRACK_ENDED post-base is not a failure; drained finalizes with no error', () => {
const h = makeHarness();
diff --git a/packages/player/src/catalog-bootstrap.ts b/packages/player/src/catalog-bootstrap.ts
index b1561c07..18391a23 100644
--- a/packages/player/src/catalog-bootstrap.ts
+++ b/packages/player/src/catalog-bootstrap.ts
@@ -177,7 +177,7 @@ interface Attempt {
export class CatalogBootstrap {
private readonly cb: CatalogBootstrapCallbacks;
- private readonly draft: 14 | 16 | 18;
+ private readonly draft: 14 | 16 | 18 | 21;
private _phase: BootstrapPhase = 'idle';
private attemptSeq = 0;
@@ -223,7 +223,7 @@ export class CatalogBootstrap {
* degradation. */
private readonly strict: boolean;
- constructor(callbacks: CatalogBootstrapCallbacks, options: { draft: 14 | 16 | 18; startMode?: 'joining' | 'legacy'; strict?: boolean }) {
+ constructor(callbacks: CatalogBootstrapCallbacks, options: { draft: 14 | 16 | 18 | 21; startMode?: 'joining' | 'legacy'; strict?: boolean }) {
this.cb = callbacks;
this.draft = options.draft;
this.startMode = options.startMode ?? 'joining';
@@ -280,6 +280,22 @@ export class CatalogBootstrap {
if (this.draft === 14 && this._phase === 'joining' && !this.attempt) {
this.beginAttempt('joining');
}
+ this.resolveEmptyFill();
+ }
+
+ /**
+ * draft-21 §3.4: the fill range never extends beyond Largest Object, and a
+ * range starting after it opens no fill stream. A SUBSCRIBE_OK without a
+ * Largest Object therefore means no fill will come: resolve the attempt as
+ * an empty track, as INVALID_RANGE does for a Joining FETCH.
+ */
+ private resolveEmptyFill(): void {
+ const attempt = this.attempt;
+ // `largest` is undefined until SUBSCRIBE_OK; null means it carried none.
+ if (this.draft !== 21 || this.largest !== null) return;
+ if (!attempt || attempt.kind !== 'joining' || attempt.cancelled) return;
+ this.cb.log('[catalog-bootstrap] SUBSCRIBE_OK has no Largest Object: the track is empty, no fill will open');
+ this.onFetchError(attempt.id, 'invalid-range');
}
// ─── FETCH side (attempt-token-guarded) ───────────────────────────
@@ -530,6 +546,7 @@ export class CatalogBootstrap {
this._phase = 'fetching';
if (kind === 'joining') this.cb.issueJoiningFetch(this.attempt.id);
this.armInactivity();
+ if (kind === 'joining') this.resolveEmptyFill();
}
/** First-payload / prefix ingestion with the profile-specific base rules. */
diff --git a/packages/player/src/player-d21-bootstrap.loopback.test.ts b/packages/player/src/player-d21-bootstrap.loopback.test.ts
new file mode 100644
index 00000000..36ebf75b
--- /dev/null
+++ b/packages/player/src/player-d21-bootstrap.loopback.test.ts
@@ -0,0 +1,187 @@
+/**
+ * Catalog bootstrap over the in-memory draft-21 loopback: a REAL MoqtPlayer
+ * drives a REAL MoqtConnection(21) client against a REAL server connection
+ * acting as a minimal catalog publisher. Draft 21 has no Joining FETCH: the
+ * catalog SUBSCRIBE carries FILL_PARAMETERS for the current group and the
+ * publisher answers on a fill fetch stream (no FETCH_OK; the FIN completes it).
+ *
+ * @see draft-ietf-moq-msf-01 §5, draft-ietf-moq-transport-21 §3.4
+ */
+
+import { describe, it, expect } from 'vitest';
+import { connectedPair } from '../../webtransport/src/testkit/pair.js';
+import { MoqtPlayer } from './player.js';
+import type { MoqtConnection } from '@openmoq/webtransport';
+import { varint } from '@openmoq/transport';
+
+const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));
+const td = new TextDecoder();
+const flush = () => new Promise((r) => setTimeout(r, 0));
+const settle = async (n = 8) => { for (let i = 0; i < n; i++) await flush(); };
+const FILL_PARAMETERS = 0x23n;
+
+const CATALOG = {
+ version: 'draft-01',
+ tracks: [
+ { name: 'video', packaging: 'loc', renderGroup: 1, isLive: true, role: 'video',
+ codec: 'av01.0.08M.10', width: 1920, height: 1080, bitrate: 1_500_000 },
+ ],
+};
+const DELTA = {
+ deltaUpdate: [{ op: 'add', tracks: [{ name: 'audio', packaging: 'loc', renderGroup: 1, isLive: true, role: 'audio', codec: 'opus', samplerate: 48000, channelConfig: '2', bitrate: 32_000 }] }],
+};
+
+interface ServerState {
+ catalogAlias: bigint;
+ catalogFill: Uint8Array | null;
+ fetches: number;
+ mediaSubs: Array<{ reqId: bigint; name: string; fill: boolean }>;
+}
+
+function wireServer(server: MoqtConnection, opts?: { resetFill?: boolean; empty?: boolean }): ServerState {
+ const state: ServerState = { catalogAlias: 40n, catalogFill: null, fetches: 0, mediaSubs: [] };
+ let nextAlias = 41n;
+ server.onFetch = () => { state.fetches += 1; };
+ server.onSubscribe = (requestId, _ns, trackName, params) => {
+ const name = td.decode(trackName);
+ const fill = (params as Map | undefined)?.get(FILL_PARAMETERS)?.[0] as Uint8Array | undefined;
+ void (async () => {
+ if (name !== 'catalog') {
+ state.mediaSubs.push({ reqId: requestId, name, fill: fill !== undefined });
+ await server.acceptSubscribe(requestId, nextAlias++);
+ return;
+ }
+ state.catalogFill = fill ?? null;
+ if (opts?.empty) {
+ // No content yet: no LARGEST_OBJECT, so no fill stream (§3.4).
+ await server.acceptSubscribe(requestId, state.catalogAlias);
+ return;
+ }
+ await server.acceptSubscribe(requestId, state.catalogAlias, {
+ parameters: new Map([[0x09n, [{ group: 5n, object: 1n }]]]) as never,
+ });
+ if (fill === undefined) return;
+ const sid = await server.openFillStream(requestId);
+ await server.sendFetchObject(sid, { groupId: 5n, subgroupId: 0n, objectId: 0n, publisherPriority: 5, payload: enc(CATALOG) });
+ if (opts?.resetFill) {
+ // No public reset API for served fetch streams: abort the writer.
+ const streams = (server as unknown as { fetchOutgoingStreams: Map })
+ .fetchOutgoingStreams;
+ await streams.get(sid)!.writer.abort(new Error('fill failed'));
+ return;
+ }
+ await server.sendFetchObject(sid, { groupId: 5n, subgroupId: 0n, objectId: 1n, publisherPriority: 5, payload: enc(DELTA) });
+ await server.closeFetchStream(sid);
+ })();
+ };
+ return state;
+}
+
+function newPlayer(client: unknown): MoqtPlayer {
+ return new MoqtPlayer({
+ url: 'https://unused.example/moq',
+ namespace: 'live/broadcast',
+ connection: client as MoqtConnection,
+ createTransport: async () => ({}) as never,
+ draftVersion: 21,
+ });
+}
+
+describe('d21 loopback — catalog bootstrap from a SUBSCRIBE fill', () => {
+ it('the fill supplies head + delta, no FETCH is sent, and the live tail applies after readiness', async () => {
+ const { client, server, errors } = await connectedPair(21);
+ const state = wireServer(server as unknown as MoqtConnection);
+ const player = newPlayer(client);
+ const events: string[] = [];
+ const received: string[][] = [];
+ player.on('catalog_received', (e) => { events.push('received'); received.push(e.catalog.tracks.map((t) => t.name)); });
+ player.on('catalog_updated', (e) => { events.push('updated'); received.push(e.catalog.tracks.map((t) => t.name)); });
+ const playerErrors: unknown[] = [];
+ player.on('error', (e) => playerErrors.push(e.error));
+
+ await player.load();
+ await settle();
+
+ // FILL_PARAMETERS = LOCATION_FILTER [1]: the current group.
+ expect(state.catalogFill && Array.from(state.catalogFill)).toEqual([0x21, 0x01, 0x01]);
+ expect(state.fetches).toBe(0);
+ expect(events).toEqual(['received']);
+ expect(received[0]).toEqual(['video', 'audio']);
+
+ const gid = await (server as unknown as MoqtConnection).openSubgroup(
+ varint(state.catalogAlias), varint(5n), varint(0n), { endOfGroup: false, publisherPriority: 128 });
+ await (server as unknown as MoqtConnection).sendObject(gid, varint(2n), enc({
+ deltaUpdate: [{ op: 'add', tracks: [{ name: 'captions', packaging: 'loc', renderGroup: 1, isLive: true, role: 'caption', codec: 'wvtt' }] }],
+ }));
+ await (server as unknown as MoqtConnection).closeSubgroup(gid);
+ await settle();
+
+ expect(events).toEqual(['received', 'updated']);
+ expect(received[1]).toEqual(['video', 'audio', 'captions']);
+ // Media subscriptions ask for no fill unless warm start is on.
+ expect(state.mediaSubs.map((m) => [m.name, m.fill])).toContainEqual(['video', false]);
+ expect(playerErrors).toEqual([]);
+ expect(errors).toEqual([]);
+
+ // PUBLISH_DONE statuses on draft 21: SUBSCRIPTION_ENDED (0x3) is gone.
+ const done = player as unknown as { normalizePublishDoneStatus(code: bigint): string };
+ expect(done.normalizePublishDoneStatus(0x2n)).toBe('ended');
+ expect(done.normalizePublishDoneStatus(0x3n)).toBe('retriable');
+ expect(done.normalizePublishDoneStatus(0x12n)).toBe('fatal-track');
+ await player.destroy();
+ });
+
+ it('an empty track opens no fill: the first live catalog object completes the bootstrap', async () => {
+ const { client, server, errors } = await connectedPair(21);
+ const state = wireServer(server as unknown as MoqtConnection, { empty: true });
+ let catalogSubs = 0;
+ const orig = server.onSubscribe!;
+ server.onSubscribe = (requestId, ns, trackName, params) => {
+ if (td.decode(trackName) === 'catalog') catalogSubs += 1;
+ orig(requestId, ns, trackName, params);
+ };
+ const player = newPlayer(client);
+ const received: string[][] = [];
+ player.on('catalog_received', (e) => received.push(e.catalog.tracks.map((t) => t.name)));
+
+ await player.load();
+ await settle();
+ expect(received).toEqual([]);
+
+ const gid = await (server as unknown as MoqtConnection).openSubgroup(
+ varint(state.catalogAlias), varint(0n), varint(0n), { endOfGroup: false, publisherPriority: 128 });
+ await (server as unknown as MoqtConnection).sendObject(gid, varint(0n), enc(CATALOG));
+ await (server as unknown as MoqtConnection).closeSubgroup(gid);
+ await settle();
+
+ expect(received).toEqual([['video']]);
+ expect(catalogSubs).toBe(1);
+ expect(state.fetches).toBe(0);
+ expect(errors).toEqual([]);
+ await player.destroy();
+ });
+
+ it('a reset fill fails the attempt and the fallback ladder recovers', async () => {
+ const { client, server, errors } = await connectedPair(21);
+ const state = wireServer(server as unknown as MoqtConnection, { resetFill: true });
+ let catalogSubs = 0;
+ const orig = server.onSubscribe!;
+ server.onSubscribe = (requestId, ns, trackName, params) => {
+ if (td.decode(trackName) === 'catalog') catalogSubs += 1;
+ orig(requestId, ns, trackName, params);
+ };
+ const player = newPlayer(client);
+ const received: string[][] = [];
+ player.on('catalog_received', (e) => received.push(e.catalog.tracks.map((t) => t.name)));
+
+ await player.load();
+ await settle(24);
+
+ // The failed prefix is recovered by the fallback ladder; whichever rung
+ // served it, the session survives and the catalog is never half-applied.
+ expect(catalogSubs + state.fetches).toBeGreaterThan(1);
+ expect(received.every((r) => r.includes('video'))).toBe(true);
+ expect(errors).toEqual([]);
+ await player.destroy();
+ });
+});
diff --git a/packages/player/src/player.ts b/packages/player/src/player.ts
index 5b0e2f7c..4d77e285 100644
--- a/packages/player/src/player.ts
+++ b/packages/player/src/player.ts
@@ -93,12 +93,19 @@ import {
getTimelineDuration,
type TimelineState,
} from './timeline-manager.js';
+import { isRequestStreamDraft } from '@openmoq/transport';
// ─── Constants ──────────────────────────────────────────────────────
/** Max objects to stage during make-before-break switch before force-completing. */
const SWITCH_STAGING_MAX_OBJECTS = 100;
+/** draft-21 FILL_PARAMETERS for joining the current group: a LOCATION_FILTER
+ * of [1] (the current group: Largest.Group + 1 - 1), the
+ * counterpart of a relative Joining FETCH at offset 0. */
+const CURRENT_GROUP_FILL = { filter: { type: 'RelativeStart' as const, groups: 1n } };
+const CATALOG_FILL = CURRENT_GROUP_FILL;
+
/** Max time (ms) to wait for a keyframe during make-before-break switch. */
const SWITCH_STAGING_TIMEOUT_MS = 3_000;
@@ -570,6 +577,9 @@ export class MoqtPlayer {
private bootstrapGeneration = 0;
/** The coordinator's CURRENT fetch: connection-scoped ownership. */
private bootstrapFetch: { conn: MoqtConnection; reqId: bigint; attempt: number; gen: number } | null = null;
+ /** draft-21 catalog SUBSCRIBEs whose fill stands in for the Joining FETCH:
+ * retiring one cancels the fill, never the subscription it rides on. */
+ private readonly catalogFillRequests = new Set();
/** Fetch data streams belonging to the bootstrap — CONNECTION-SCOPED: an
* old session's stream 0 must never be read as the new session's stream 0
* during migration. */
@@ -3523,6 +3533,7 @@ export class MoqtPlayer {
this.fetchStreamAliases.clear();
this.fetchStreamRequestIds.clear();
this.pendingFetchStreams.clear();
+ this.catalogFillRequests.clear();
this.refusedFetchRequests.clear();
this.quarantinedFetchRequests.clear();
this.droppedFetchStreams.clear();
@@ -3939,6 +3950,13 @@ export class MoqtPlayer {
* Profile is unknowable before the first catalog parse, so this depends
* only on configuration.
*/
+ private usesCatalogFill(conn: MoqtConnection): boolean {
+ // draft 21 has no Joining FETCH: the SUBSCRIBE carries FILL_PARAMETERS
+ // and the fill fetch stream (FETCH_HEADER with the SUBSCRIBE's Request ID)
+ // is the MSF-01 §5 prefix. There is no FETCH_OK; a FIN completes it.
+ return conn.draftVersion === 21;
+ }
+
private resolvedCatalogMode(_conn: MoqtConnection): 'joining-fetch' | 'subscribe' {
const mode = this.config.catalogBootstrap ?? 'auto';
if (mode === 'subscribe') return 'subscribe';
@@ -4007,6 +4025,7 @@ export class MoqtPlayer {
const gen = this.bootstrapGeneration;
return {
subscriptionFilter: { type: 'LargestObject' as const },
+ ...(this.usesCatalogFill(conn) ? { fill: CATALOG_FILL } : {}),
onRequestId: (id: bigint) => {
if (gen !== this.bootstrapGeneration) return;
this.catalogRequestId = id;
@@ -4044,6 +4063,17 @@ export class MoqtPlayer {
resetManager: () => this.catalogManager?.reset(),
currentState: () => this.catalogManager?.currentState ?? null,
issueJoiningFetch: (attempt) => {
+ if (this.usesCatalogFill(conn)) {
+ void Promise.resolve().then(() => {
+ if (!live()) return;
+ const subReqId = this.catalogRequestId;
+ if (subReqId === null) { coord.onFetchError(attempt, 'refused'); return; }
+ this.catalogFillRequests.add(subReqId);
+ coord.onFetchOk(attempt, { group: 0n, object: 0n }, false);
+ this.registerBootstrapFetch(conn, subReqId, attempt, gen);
+ });
+ return;
+ }
void (async () => {
let myFetchReqId: bigint | null = null;
try {
@@ -4189,7 +4219,7 @@ export class MoqtPlayer {
},
log: (msg, ...args) => this.log.debug(msg, ...args),
}, {
- draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18,
+ draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18 | 21,
strict: this.strictCatalogMode(),
});
@@ -4222,6 +4252,20 @@ export class MoqtPlayer {
}
}
+ /** A recovery candidate's draft-21 fill stream can arrive before the
+ * coordinator asks for it (the fill rides on the SUBSCRIBE): claim it. */
+ private claimParkedRecoveryFill(
+ recovery: NonNullable, reqId: bigint, attempt: number,
+ ): void {
+ for (const [streamId, parked] of [...this.pendingFetchStreams]) {
+ if (parked.requestId !== reqId) continue;
+ this.pendingFetchStreams.delete(streamId);
+ recovery.fetchStreams.set(streamId, attempt);
+ for (const obj of parked.objects) recovery.coord.onFetchObject(attempt, this.toCatalogEvent(obj));
+ if (parked.terminal !== undefined) recovery.coord.onFetchStreamClosed(attempt, parked.terminal === 'fin');
+ }
+ }
+
/** Emit the raw-catalog diagnostic exactly as the legacy path does. */
private emitCatalogRaw(obj: MoqtObject): void {
if (obj.kind !== 'data') return;
@@ -4336,7 +4380,11 @@ export class MoqtPlayer {
* supersession, rung transitions, and candidate teardown.
*/
private retireBootstrapFetch(conn: MoqtConnection, reqId: bigint, opts?: { attempt?: number; extraStreams?: Iterable }): void {
- void conn.fetchCancel(varint(reqId)).catch(() => { /* gone */ });
+ if (this.catalogFillRequests.delete(reqId)) {
+ void conn.cancelFill(reqId).catch(() => { /* gone */ });
+ } else {
+ void conn.fetchCancel(varint(reqId)).catch(() => { /* gone */ });
+ }
this.quarantineFetchRequest(reqId);
for (const [sid, entry] of this.bootstrapFetchStreams) {
// Attempt-scoped: retiring attempt A must never sweep streams that a
@@ -4396,6 +4444,22 @@ export class MoqtPlayer {
resetManager: () => manager.reset(),
currentState: () => manager.currentState,
issueJoiningFetch: (attempt) => {
+ if (this.usesCatalogFill(conn)) {
+ void Promise.resolve().then(() => {
+ if (!live()) return;
+ const subReqId = ownedAsCandidate() ? this.catalogRecovery!.reqId : this.catalogRequestId;
+ if (subReqId == null) { coord.onFetchError(attempt, 'refused'); return; }
+ this.catalogFillRequests.add(subReqId);
+ coord.onFetchOk(attempt, { group: 0n, object: 0n }, false);
+ if (ownedAsCandidate()) {
+ this.catalogRecovery!.fetch = { reqId: subReqId, attempt };
+ this.claimParkedRecoveryFill(this.catalogRecovery!, subReqId, attempt);
+ } else if (ownedAsMain()) {
+ this.registerBootstrapFetch(conn, subReqId, attempt, this.bootstrapGeneration);
+ }
+ });
+ return;
+ }
void (async () => {
let myFetchReqId: bigint | null = null;
try {
@@ -4562,7 +4626,7 @@ export class MoqtPlayer {
},
log: (msg, ...args) => this.log.debug(msg, ...args),
}, {
- draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18,
+ draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18 | 21,
// Legacy catalog mode (the explicit 'subscribe' compatibility option):
// the candidate is a fresh AbsoluteStart subscribe — subscription-only
// retrieval, ready on the first acceptable base; NEVER a Joining FETCH.
@@ -4594,6 +4658,7 @@ export class MoqtPlayer {
: { type: 'LargestObject' as const };
const reqId = await conn.subscribe(nsBytes, nameBytes, {
subscriptionFilter: candidateFilter,
+ ...(candidateFilter.type === 'LargestObject' && this.usesCatalogFill(conn) ? { fill: CATALOG_FILL } : {}),
onRequestId: (id: bigint) => {
const r = this.catalogRecovery;
if (!live() || !r) return;
@@ -4924,13 +4989,14 @@ export class MoqtPlayer {
// NEGOTIATED draft: the status tables differ per draft, and the session's
// actual draft can diverge from configuration under auto-negotiation.
const draft = this.connection?.draftVersion ?? this.config.draftVersion ?? 16;
- if (code === 0x2n || code === 0x3n) return 'ended'; // TRACK_ENDED / SUBSCRIPTION_ENDED
+ if (code === 0x2n) return 'ended'; // TRACK_ENDED
+ if (code === 0x3n && draft !== 21) return 'ended'; // SUBSCRIPTION_ENDED (gone in draft 21)
if (code === 0x4n) return 'going-away'; // GOING_AWAY
if (code === 0x1n) return 'fatal-track'; // UNAUTHORIZED
if (draft === 14 && code === 0x7n) return 'fatal-track'; // d14 MALFORMED_TRACK
- if (draft !== 14 && code === 0x12n) return 'fatal-track'; // d16/18 MALFORMED_TRACK
+ if (draft !== 14 && code === 0x12n) return 'fatal-track'; // d16+ MALFORMED_TRACK
// INTERNAL_ERROR(0x0), EXPIRED/TOO_FAR_BEHIND (0x5/0x6, either draft
- // orientation), UPDATE_FAILED(0x8), d18 EXCESSIVE_LOAD(0x9), unknown.
+ // orientation), UPDATE_FAILED(0x8), d18/21 EXCESSIVE_LOAD(0x9), unknown.
if (code !== 0x0n && code !== 0x5n && code !== 0x6n && code !== 0x8n && code !== 0x9n) {
this.log.warn('[catalog-bootstrap] unknown PUBLISH_DONE status 0x%s — treated as retriable', code.toString(16));
}
@@ -5230,6 +5296,7 @@ export class MoqtPlayer {
this.fetchStreamAliases.clear();
this.fetchStreamRequestIds.clear();
this.pendingFetchStreams.clear();
+ this.catalogFillRequests.clear();
this.refusedFetchRequests.clear();
this.quarantinedFetchRequests.clear();
this.droppedFetchStreams.clear();
@@ -6286,7 +6353,7 @@ export class MoqtPlayer {
// uses 0x5 (§15.10.3), draft-14/16 use 0x6 (§13.4.3). On draft-18, 0x6 is
// EXPIRED, so comparing against the wrong table would both miss real
// TOO_FAR_BEHIND and mis-fire recovery on EXPIRED.
- const tooFarBehind = this.connection?.draftVersion === 18
+ const tooFarBehind = isRequestStreamDraft(this.connection?.draftVersion)
? PublishDoneCode18.TOO_FAR_BEHIND
: PublishDoneCode.TOO_FAR_BEHIND;
if (statusCode === BigInt(tooFarBehind)) {
@@ -7503,8 +7570,14 @@ export class MoqtPlayer {
}
// Warm start overrides ONLY the filter — configured subscribe options
// (deliveryTimeout, subscriberPriority, groupOrder) are preserved.
+ // draft 21: the SUBSCRIBE itself asks for the current group as a fill.
+ const warmFill = warmStart && this.connection.draftVersion === 21;
const mediaOptions = warmStart
- ? { ...(subscribeOptions ?? {}), subscriptionFilter: { type: 'LargestObject' as const } }
+ ? {
+ ...(subscribeOptions ?? {}),
+ subscriptionFilter: { type: 'LargestObject' as const },
+ ...(warmFill ? { fill: CURRENT_GROUP_FILL } : {}),
+ }
: (subscribeOptions ?? defaultMediaSubscriptionFilter(track?.isLive === true));
// Pre-send ownership (§9.10): register inside onRequestId — a
// zero-latency SUBSCRIBE_OK must find the pending entry already in place. Adapters
@@ -7539,7 +7612,14 @@ export class MoqtPlayer {
// Re-check after await — destroy() may have been called
if (!this.subscriptionManager) return;
- if (warmStart) {
+ if (warmFill) {
+ // The fill stream carries the SUBSCRIBE's Request ID: route it under
+ // that ID like a warm-start joining FETCH (parked until the alias binds).
+ this.registerMediaFetch(reqIdBigInt, {
+ trackName: name, mediaType, subscriptionRequestId: reqIdBigInt, trackAlias: null, warmStart: true,
+ });
+ this.log.info('[warm-start] fill requested on subscribe %s for %s "%s"', reqIdBigInt, mediaType, name);
+ } else if (warmStart) {
// §9.16.2 / §10.12.2: a Joining Fetch may reference a PENDING
// subscription, so this is issued immediately after SUBSCRIBE without
// awaiting SUBSCRIBE_OK. Registering the fetch under the LIVE track's
diff --git a/packages/quic/src/connect.test.ts b/packages/quic/src/connect.test.ts
index afb99bb7..fd35d9ed 100644
--- a/packages/quic/src/connect.test.ts
+++ b/packages/quic/src/connect.test.ts
@@ -299,6 +299,21 @@ describe('connectQuicWithRuntime', () => {
expect(await transport.datagrams.readable.getReader().read()).toEqual({ done: false, value: new Uint8Array([4, 5]) });
});
+ it('offers moqt-21 when asked for draft 21 and reports it as the protocol', async () => {
+ const session = new FakeSession({ protocol: 'moqt-21' });
+ const fake = fakeRuntime(session);
+ const transport = await connectQuicWithRuntime('moqt://relay.example/moq', { draft: 21 }, fake.runtime);
+ expect(fake.options()).toMatchObject({ alpn: 'moqt-21' });
+ expect(transport.protocol).toBe('moqt-21');
+ });
+
+ it('refuses a handshake that negotiated a different draft than the one offered', async () => {
+ const session = new FakeSession({ protocol: 'moqt-18' });
+ const fake = fakeRuntime(session);
+ await expect(connectQuicWithRuntime('moqt://relay.example/moq', { draft: 21 }, fake.runtime))
+ .rejects.toThrow(/negotiated ALPN "moqt-18"; expected "moqt-21"/);
+ });
+
it('fails startup even when rejecting an invalid incoming stream throws', async () => {
const session = new FakeSession();
const fake = fakeRuntime(session);
diff --git a/packages/quic/src/connect.ts b/packages/quic/src/connect.ts
index 7261d7fc..e32b012d 100644
--- a/packages/quic/src/connect.ts
+++ b/packages/quic/src/connect.ts
@@ -5,11 +5,11 @@ import {
BoundedReadableQueue,
NodeQuicTransport,
routeNativeStream,
+ type MoqtQuicProtocol,
type MoqtQuicTransport,
} from './transport.js';
import type { WebTransportBidirectionalStream } from '@openmoq/webtransport';
-const ALPN = 'moqt-18';
const DEFAULT_PORT = '443';
const DEFAULT_MAX_DATAGRAM_FRAME_SIZE = 1200;
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
@@ -24,6 +24,8 @@ export interface QuicConnectOptions {
readonly handshakeTimeoutMs?: number;
/** Maximum QUIC DATAGRAM frame size advertised to the peer. */
readonly maxDatagramFrameSize?: number;
+ /** MOQT draft to offer: ALPN `moqt-18` (default) or `moqt-21`. */
+ readonly draft?: 18 | 21;
}
export interface ParsedMoqtUri {
@@ -96,7 +98,8 @@ export function parseMoqtUri(input: string | URL): ParsedMoqtUri {
* Connect to a native-QUIC MoQT endpoint using Node's experimental QUIC API.
*
* Requires a Node build configured with `--experimental-quic`, launched with
- * `--experimental-quic`. The returned transport is draft-18 only.
+ * `--experimental-quic`. The returned transport speaks the one draft offered in
+ * `options.draft` (18 by default, or 21).
*/
export async function connectQuic(
uri: string | URL,
@@ -188,6 +191,9 @@ export async function connectQuicWithRuntime(
runtime: NativeQuicRuntime,
): Promise {
const parsed = parseMoqtUri(uri);
+ const draft = options.draft ?? 18;
+ if (draft !== 18 && draft !== 21) throw new RangeError(`native QUIC supports drafts 18 and 21, not ${String(draft)}`);
+ const ALPN: MoqtQuicProtocol = `moqt-${draft}`;
const maxDatagramFrameSize = options.maxDatagramFrameSize ?? DEFAULT_MAX_DATAGRAM_FRAME_SIZE;
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
positiveInteger(maxDatagramFrameSize, 'maxDatagramFrameSize');
@@ -296,6 +302,7 @@ export async function connectQuicWithRuntime(
bidirectional,
datagrams,
() => observedSessionError,
+ ALPN,
);
} catch (error) {
const reported = observedSessionError ?? closedFailure ?? error;
diff --git a/packages/quic/src/index.ts b/packages/quic/src/index.ts
index 44d6055c..06ed14a1 100644
--- a/packages/quic/src/index.ts
+++ b/packages/quic/src/index.ts
@@ -2,12 +2,13 @@
* Experimental native QUIC binding for Node.js.
*
* This package requires Node's experimental `node:quic` module and currently
- * supports MOQT draft 18 only. Browser applications should continue to use
- * `@openmoq/webtransport` with a WebTransport implementation.
+ * supports MOQT drafts 18 and 21 (one per connection, chosen with `draft`).
+ * Browser applications should continue to use `@openmoq/webtransport` with a
+ * WebTransport implementation.
*
* @module
*/
export { connectQuic, parseMoqtUri } from './connect.js';
export type { ParsedMoqtUri, QuicConnectOptions } from './connect.js';
-export type { MoqtQuicTransport } from './transport.js';
+export type { MoqtQuicProtocol, MoqtQuicTransport } from './transport.js';
diff --git a/packages/quic/src/transport.ts b/packages/quic/src/transport.ts
index 8446e319..fb562fdb 100644
--- a/packages/quic/src/transport.ts
+++ b/packages/quic/src/transport.ts
@@ -13,6 +13,7 @@ const STREAM_RESET_CODES = new Set([0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 9n, 0x12n]);
const SESSION_ERROR_CODES = new Set([
0n, 1n, 2n, 3n, 4n, 5n, 6n, 8n, 9n,
0x10n, 0x11n, 0x12n, 0x13n, 0x14n, 0x15n, 0x16n, 0x17n, 0x18n, 0x19n, 0x1an,
+ 0x1bn, // draft-21 TOO_MANY_REQUEST_UPDATES
]);
interface MoqtSetupRouting {
@@ -20,10 +21,13 @@ interface MoqtSetupRouting {
readonly path: string;
}
+/** The MOQT ALPN tokens the native binding can negotiate. */
+export type MoqtQuicProtocol = 'moqt-18' | 'moqt-21';
+
/** Native QUIC transport plus the routing fields required in MOQT SETUP. */
export interface MoqtQuicTransport extends WebTransportLike {
readonly kind: 'quic';
- readonly protocol: 'moqt-18';
+ readonly protocol: MoqtQuicProtocol;
readonly maxDatagramSize: number;
readonly setupOptions: MoqtSetupRouting;
}
@@ -100,7 +104,6 @@ export class BoundedReadableQueue {
/** Web-Streams facade over one Node QUIC session. */
export class NodeQuicTransport implements MoqtQuicTransport {
readonly kind = 'quic' as const;
- readonly protocol = 'moqt-18' as const;
readonly maxDatagramSize: number;
readonly incomingUnidirectionalStreams: ReadableStream>;
readonly incomingBidirectionalStreams: ReadableStream;
@@ -119,6 +122,7 @@ export class NodeQuicTransport implements MoqtQuicTransport {
private readonly incomingBidi: BoundedReadableQueue,
private readonly incomingDatagrams: BoundedReadableQueue,
sessionError: () => unknown,
+ readonly protocol: MoqtQuicProtocol = 'moqt-18',
) {
this.maxDatagramSize = session.maxDatagramSize;
this.incomingUnidirectionalStreams = incomingUni.readable;
diff --git a/packages/transport/README.md b/packages/transport/README.md
index 075c5a1b..8f152508 100644
--- a/packages/transport/README.md
+++ b/packages/transport/README.md
@@ -4,7 +4,7 @@
# @openmoq/transport
-Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18, with no I/O.
+Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18/21, with no I/O.
Part of [Playa](https://github.com/openmoq/moq-playa) — a TypeScript implementation of Media over QUIC (MoQ). See the [project README](https://github.com/openmoq/moq-playa#readme) for architecture and API documentation.
diff --git a/packages/transport/package.json b/packages/transport/package.json
index b768eabf..dc29b438 100644
--- a/packages/transport/package.json
+++ b/packages/transport/package.json
@@ -1,7 +1,7 @@
{
"name": "@openmoq/transport",
"version": "0.5.9",
- "description": "Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18, with no I/O.",
+ "description": "Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18/21, with no I/O.",
"license": "Apache-2.0",
"author": "OpenMOQ (https://github.com/openmoq)",
"contributors": [
diff --git a/packages/transport/src/control/codec.ts b/packages/transport/src/control/codec.ts
index d9b45ca2..b8761d17 100644
--- a/packages/transport/src/control/codec.ts
+++ b/packages/transport/src/control/codec.ts
@@ -87,6 +87,8 @@ export function createControlCodec(version: DraftVersion = 16): ControlCodec {
return new Draft14Codec();
case 18:
return new Draft18Codec();
+ case 21:
+ return new Draft18Codec(21);
default:
// Unknown/unwired draft: a programming error, not a silent fallback.
throw new Error(`createControlCodec: draft-${version as number} control codec is not yet implemented`);
diff --git a/packages/transport/src/control/codes-18.ts b/packages/transport/src/control/codes-18.ts
index 956d81e0..d4c1b4c2 100644
--- a/packages/transport/src/control/codes-18.ts
+++ b/packages/transport/src/control/codes-18.ts
@@ -44,6 +44,8 @@ export const ControlMessageType18 = {
FETCH: 0x16,
FETCH_OK: 0x18,
PUBLISH: 0x1d,
+ /** draft-21 §9.10 only: a publisher's report of its subscription state. */
+ PUBLISH_STATE_NOTIFY: 0x22,
// PUBLISH_OK (0x1E) intentionally omitted — see the note above (REQUEST_OK shorthand).
SUBSCRIBE_NAMESPACE: 0x50,
SUBSCRIBE_TRACKS: 0x51,
@@ -63,6 +65,13 @@ export const SetupOption18 = {
MOQT_IMPLEMENTATION: 0x07,
} as const;
+/** draft-21 Setup Options added to the draft-18 set (§9.1.6, §9.1.7). */
+export const SetupOption21 = {
+ ...SetupOption18,
+ MAX_FILTER_RANGES: 0x06,
+ MAX_REQUEST_UPDATES: 0x08,
+} as const;
+
/**
* Value-encoding kind for a draft-18 message parameter (§10.2).
* `namespace` is a Track Namespace structure (§2.4.1): a vi64 field count
@@ -92,6 +101,35 @@ export const MessageParam18: Record = {
+ OBJECT_DELIVERY_TIMEOUT: { type: 0x02, kind: 'varint' },
+ AUTHORIZATION_TOKEN: { type: 0x03, kind: 'bytes' },
+ RENDEZVOUS_TIMEOUT: { type: 0x04, kind: 'varint' },
+ SUBGROUP_DELIVERY_TIMEOUT: { type: 0x06, kind: 'varint' },
+ EXPIRES: { type: 0x08, kind: 'varint' },
+ LARGEST_OBJECT: { type: 0x09, kind: 'location' },
+ FILL_TIMEOUT: { type: 0x0a, kind: 'varint' },
+ FORWARD: { type: 0x10, kind: 'uint8' },
+ SUBSCRIBER_PRIORITY: { type: 0x20, kind: 'uint8' },
+ LOCATION_FILTER: { type: 0x21, kind: 'bytes' },
+ GROUP_ORDER: { type: 0x22, kind: 'uint8' },
+ FILL_PARAMETERS: { type: 0x23, kind: 'bytes' },
+ SUBGROUP_FILTER: { type: 0x25, kind: 'bytes' },
+ OBJECTID_FILTER: { type: 0x26, kind: 'bytes' },
+ PRIORITY_FILTER: { type: 0x27, kind: 'bytes' },
+ OBJECT_PROPERTY_FILTER: { type: 0x28, kind: 'bytes' },
+ TRACK_PROPERTY_FILTER: { type: 0x29, kind: 'bytes' },
+ NEW_GROUP_REQUEST: { type: 0x32, kind: 'varint' },
+ TRACK_NAMESPACE_PREFIX: { type: 0x34, kind: 'namespace' },
+ INCLUDE_PROPERTIES: { type: 0x35, kind: 'uint8' },
+} as const;
+
/** Property type ranges (§2.5, §12). Properties are carried by control messages
* (Track Properties) and data objects (Object Properties); the type-space is shared. */
export const PropertyRange18 = {
diff --git a/packages/transport/src/control/decoder.ts b/packages/transport/src/control/decoder.ts
index 05c18cba..c662c88d 100644
--- a/packages/transport/src/control/decoder.ts
+++ b/packages/transport/src/control/decoder.ts
@@ -445,6 +445,7 @@ function decodePayloadFields(
// Draft-18-only types — not decodable by the draft-14/16 decoder
case 'SUBSCRIBE_TRACKS':
case 'PUBLISH_BLOCKED':
+ case 'PUBLISH_STATE_NOTIFY':
throw new ProtocolViolationError(`Draft-18-only message type "${typeName}" cannot be decoded by draft-16 decoder`);
}
}
diff --git a/packages/transport/src/control/draft18-codec.ts b/packages/transport/src/control/draft18-codec.ts
index 3ed6df25..932785a8 100644
--- a/packages/transport/src/control/draft18-codec.ts
+++ b/packages/transport/src/control/draft18-codec.ts
@@ -67,9 +67,12 @@ import {
decodeMessageParams18,
messageParams18EncodingLength,
DEFAULT_MESSAGE_PARAM_REGISTRY,
+ DRAFT21_MESSAGE_PARAM_REGISTRY,
type MessageParams18,
+ type MessageParamRegistry,
type MessageParamValue,
} from './message-params-18.js';
+import { encodeLocationFilterFields, decodeLocationFilterFields } from './subscription-filter.js';
import {
encodeTrackProperties18,
decodeTrackProperties18,
@@ -160,6 +163,9 @@ const TEXT_DECODER = new TextDecoder();
/** §10.4: maximum New Session URI length; longer → PROTOCOL_VIOLATION. */
const GOAWAY_MAX_URI_LENGTH = 8192;
+/** draft-21 LOCATION_FILTER (§9.20.10), the 0x21 message parameter. */
+const LOCATION_FILTER_TYPE = 0x21n;
+
/** §1.4.2: maximum Reason Phrase length in bytes; longer → PROTOCOL_VIOLATION. */
const REASON_PHRASE_MAX_LENGTH = 1024;
@@ -280,8 +286,8 @@ function isLocation(v: ParameterValue): v is Location {
return typeof v === 'object' && v !== null && !(v instanceof Uint8Array) && !Array.isArray(v);
}
-function toTypedParam(type: bigint, v: ParameterValue): MessageParamValue {
- const kind = DEFAULT_MESSAGE_PARAM_REGISTRY.get(type);
+function toTypedParam(type: bigint, v: ParameterValue, registry: MessageParamRegistry): MessageParamValue {
+ const kind = registry.get(type);
if (kind === undefined) {
throw new ProtocolViolationError(`Unknown draft-18 message parameter 0x${type.toString(16)}`);
}
@@ -318,7 +324,7 @@ function toTypedParam(type: bigint, v: ParameterValue): MessageParamValue {
}
}
-function paramsToTyped(params: Parameters): MessageParams18 {
+function paramsToTyped(params: Parameters, registry: MessageParamRegistry): MessageParams18 {
const out = new Map();
for (const [type, values] of params) {
if (type === MessageParam.FORWARD) {
@@ -328,7 +334,7 @@ function paramsToTyped(params: Parameters): MessageParams18 {
throw new ProtocolViolationError('FORWARD must be exactly one value, 0 or 1');
}
}
- out.set(type, values.map((v) => toTypedParam(type, v)));
+ out.set(type, values.map((v) => toTypedParam(type, v, registry)));
}
return out;
}
@@ -363,7 +369,16 @@ const notImplemented = (what: string): never => {
};
export class Draft18Codec implements ControlCodec {
- readonly version = 18 as const;
+ /**
+ * @param version 18, or 21 for the draft-21 variant of the same stream
+ * model (its differing messages branch on this).
+ */
+ constructor(readonly version: 18 | 21 = 18) {}
+
+ /** The message-parameter table of this codec's draft. */
+ private get paramRegistry(): MessageParamRegistry {
+ return this.version === 21 ? DRAFT21_MESSAGE_PARAM_REGISTRY : DEFAULT_MESSAGE_PARAM_REGISTRY;
+ }
encode(msg: ControlMessage): Uint8Array {
switch (msg.type) {
@@ -380,7 +395,7 @@ export class Draft18Codec implements ControlCodec {
case 'REQUEST_ERROR':
return this.frame(ControlMessageType18.REQUEST_ERROR, this.encodeRequestError(msg));
case 'FETCH':
- return this.frame(ControlMessageType18.FETCH, this.encodeFetch(msg));
+ return this.frame(ControlMessageType18.FETCH, this.version === 21 ? this.encodeFetch21(msg) : this.encodeFetch(msg));
case 'FETCH_OK':
return this.frame(ControlMessageType18.FETCH_OK, this.encodeFetchOk(msg));
case 'TRACK_STATUS':
@@ -403,6 +418,11 @@ export class Draft18Codec implements ControlCodec {
return this.frame(ControlMessageType18.PUBLISH_BLOCKED, this.encodePublishBlocked(msg));
case 'GOAWAY':
return this.frame(ControlMessageType18.GOAWAY, this.encodeGoaway(msg));
+ case 'PUBLISH_STATE_NOTIFY':
+ if (this.version !== 21) {
+ throw new ProtocolViolationError('Draft18Codec: PUBLISH_STATE_NOTIFY exists only in draft 21');
+ }
+ return this.frame(ControlMessageType18.PUBLISH_STATE_NOTIFY, this.encodeParamsOnly(msg.parameters));
case 'PUBLISH_NAMESPACE_DONE':
// draft-18 §3.3.2 removed this message — withdrawal is a request-stream
// cancellation. It must never reach the wire.
@@ -436,7 +456,7 @@ export class Draft18Codec implements ControlCodec {
case ControlMessageType18.REQUEST_ERROR:
return { message: this.decodeRequestError(payload), bytesRead: total };
case ControlMessageType18.FETCH:
- return { message: this.decodeFetch(payload), bytesRead: total };
+ return { message: this.version === 21 ? this.decodeFetch21(payload) : this.decodeFetch(payload), bytesRead: total };
case ControlMessageType18.FETCH_OK:
return { message: this.decodeFetchOk(payload), bytesRead: total };
case ControlMessageType18.TRACK_STATUS:
@@ -459,6 +479,12 @@ export class Draft18Codec implements ControlCodec {
return { message: this.decodePublishBlocked(payload), bytesRead: total };
case ControlMessageType18.GOAWAY:
return { message: this.decodeGoaway(payload), bytesRead: total };
+ case ControlMessageType18.PUBLISH_STATE_NOTIFY:
+ if (this.version !== 21) return notImplemented(`decode of type 0x${type.toString(16)}`);
+ return {
+ message: { type: 'PUBLISH_STATE_NOTIFY', parameters: this.decodeParamsOnly(payload, 'PUBLISH_STATE_NOTIFY') },
+ bytesRead: total,
+ };
default:
return notImplemented(`decode of type 0x${type.toString(16)}`);
}
@@ -494,17 +520,17 @@ export class Draft18Codec implements ControlCodec {
private encodeSubscribe(msg: Subscribe): Uint8Array {
// §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes.
validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true });
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
vi64EncodingLength(msg.requestId) +
vi64TupleLength(msg.trackNamespace) +
vi64BytesLength(msg.trackName) +
- messageParams18EncodingLength(typed);
+ messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
p += writeVi64Tuple(msg.trackNamespace, buf, p);
p += writeVi64Bytes(msg.trackName, buf, p);
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
@@ -513,7 +539,7 @@ export class Draft18Codec implements ControlCodec {
const rid = readVi64(payload, p); p += rid.bytesRead;
const ns = readVi64Tuple(payload, p); p += ns.bytesRead;
const name = readVi64Bytes(payload, p); p += name.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
// SUBSCRIBE has no field after Parameters; any trailing bytes are malformed.
if (p !== payload.length) {
@@ -537,17 +563,17 @@ export class Draft18Codec implements ControlCodec {
private encodeTrackStatus(msg: TrackStatus): Uint8Array {
// §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes.
validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true });
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
vi64EncodingLength(msg.requestId) +
vi64TupleLength(msg.trackNamespace) +
vi64BytesLength(msg.trackName) +
- messageParams18EncodingLength(typed);
+ messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
p += writeVi64Tuple(msg.trackNamespace, buf, p);
p += writeVi64Bytes(msg.trackName, buf, p);
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
@@ -556,7 +582,7 @@ export class Draft18Codec implements ControlCodec {
const rid = readVi64(payload, p); p += rid.bytesRead;
const ns = readVi64Tuple(payload, p); p += ns.bytesRead;
const name = readVi64Bytes(payload, p); p += name.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
if (p !== payload.length) {
throw new ProtocolViolationError('TRACK_STATUS: unexpected trailing bytes after parameters');
@@ -581,15 +607,15 @@ export class Draft18Codec implements ControlCodec {
// §2.4.1: PUBLISH_NAMESPACE carries a full Track Namespace (no track name):
// 0-32 non-empty fields, total ≤ 4096 bytes.
validateTrackNamespace(msg.trackNamespace, { allowEmpty: true });
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
vi64EncodingLength(msg.requestId) +
vi64TupleLength(msg.trackNamespace) +
- messageParams18EncodingLength(typed);
+ messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
p += writeVi64Tuple(msg.trackNamespace, buf, p);
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
@@ -597,7 +623,7 @@ export class Draft18Codec implements ControlCodec {
let p = 0;
const rid = readVi64(payload, p); p += rid.bytesRead;
const ns = readVi64Tuple(payload, p); p += ns.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
if (p !== payload.length) {
throw new ProtocolViolationError('PUBLISH_NAMESPACE: unexpected trailing bytes after parameters');
@@ -620,20 +646,20 @@ export class Draft18Codec implements ControlCodec {
private encodePublish(msg: Publish): Uint8Array {
// §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes.
validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true });
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
vi64EncodingLength(msg.requestId) +
vi64TupleLength(msg.trackNamespace) +
vi64BytesLength(msg.trackName) +
vi64EncodingLength(msg.trackAlias) +
- messageParams18EncodingLength(typed) +
+ messageParams18EncodingLength(typed, this.paramRegistry) +
trackProperties18EncodingLength(trackPropsOf(msg));
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
p += writeVi64Tuple(msg.trackNamespace, buf, p);
p += writeVi64Bytes(msg.trackName, buf, p);
p += writeVi64(msg.trackAlias, buf, p);
- const params = encodeMessageParams18(typed);
+ const params = encodeMessageParams18(typed, this.paramRegistry);
buf.set(params, p); p += params.length;
buf.set(encodeTrackProperties18(trackPropsOf(msg)), p);
return buf;
@@ -645,7 +671,7 @@ export class Draft18Codec implements ControlCodec {
const ns = readVi64Tuple(payload, p); p += ns.bytesRead;
const name = readVi64Bytes(payload, p); p += name.bytesRead;
const alias = readVi64(payload, p); p += alias.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {})
p += props.bytesRead;
@@ -709,15 +735,15 @@ export class Draft18Codec implements ControlCodec {
prefix: Uint8Array[],
parameters: Parameters,
): Uint8Array {
- const typed = paramsToTyped(parameters);
+ const typed = paramsToTyped(parameters, this.paramRegistry);
const len =
vi64EncodingLength(requestId) +
vi64TupleLength(prefix) +
- messageParams18EncodingLength(typed);
+ messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(requestId, buf, 0);
p += writeVi64Tuple(prefix, buf, p);
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
@@ -729,7 +755,7 @@ export class Draft18Codec implements ControlCodec {
let p = 0;
const rid = readVi64(payload, p); p += rid.bytesRead;
const prefix = readVi64Tuple(payload, p); p += prefix.bytesRead; // 0 fields allowed
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
if (p !== payload.length) {
throw new ProtocolViolationError(`${name}: unexpected trailing bytes after parameters`);
@@ -781,12 +807,14 @@ export class Draft18Codec implements ControlCodec {
throw new ProtocolViolationError(`GOAWAY New Session URI length ${uri.length} exceeds maximum ${GOAWAY_MAX_URI_LENGTH} bytes`);
}
const timeout = msg.timeout ?? 0n;
+ // draft-21 §9.2 dropped the Request ID.
+ const requestId = this.version === 21 ? undefined : msg.requestId;
let len = vi64BytesLength(uri) + vi64EncodingLength(timeout);
- if (msg.requestId !== undefined) len += vi64EncodingLength(msg.requestId);
+ if (requestId !== undefined) len += vi64EncodingLength(requestId);
const buf = new Uint8Array(len);
let p = writeVi64Bytes(uri, buf, 0);
p += writeVi64(timeout, buf, p);
- if (msg.requestId !== undefined) writeVi64(msg.requestId, buf, p);
+ if (requestId !== undefined) writeVi64(requestId, buf, p);
return buf;
}
@@ -800,12 +828,14 @@ export class Draft18Codec implements ControlCodec {
// Request ID is present only on the control stream (§10.4). The codec is
// context-free: read it iff bytes remain, and expose it optionally.
let requestId: bigint | undefined;
- if (p < payload.length) {
+ if (p < payload.length && this.version !== 21) {
const rid = readVi64(payload, p); p += rid.bytesRead;
requestId = rid.value;
}
if (p !== payload.length) {
- throw new ProtocolViolationError(`GOAWAY: ${payload.length - p} trailing bytes after Request ID`);
+ throw new ProtocolViolationError(this.version === 21
+ ? `GOAWAY: ${payload.length - p} trailing bytes after Timeout`
+ : `GOAWAY: ${payload.length - p} trailing bytes after Request ID`);
}
const msg: Goaway = {
type: 'GOAWAY',
@@ -834,18 +864,18 @@ export class Draft18Codec implements ControlCodec {
// the bidirectional request stream it is sent on identifies the target.
private encodeRequestUpdate(msg: RequestUpdate): Uint8Array {
- const typed = paramsToTyped(msg.parameters);
- const len = vi64EncodingLength(msg.requestId) + messageParams18EncodingLength(typed);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
+ const len = vi64EncodingLength(msg.requestId) + messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
private decodeRequestUpdate(payload: Uint8Array): DecodedControlMessage {
let p = 0;
const rid = readVi64(payload, p); p += rid.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
if (p !== payload.length) {
throw new ProtocolViolationError('REQUEST_UPDATE: unexpected trailing bytes after parameters');
@@ -857,14 +887,14 @@ export class Draft18Codec implements ControlCodec {
// ── SUBSCRIBE_OK (response, §10.8 — no Request ID) ─────────────────
private encodeSubscribeOk(msg: SubscribeOk): Uint8Array {
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
vi64EncodingLength(msg.trackAlias) +
- messageParams18EncodingLength(typed) +
+ messageParams18EncodingLength(typed, this.paramRegistry) +
trackProperties18EncodingLength(trackPropsOf(msg));
const buf = new Uint8Array(len);
let p = writeVi64(msg.trackAlias, buf, 0);
- const params = encodeMessageParams18(typed);
+ const params = encodeMessageParams18(typed, this.paramRegistry);
buf.set(params, p); p += params.length;
buf.set(encodeTrackProperties18(trackPropsOf(msg)), p);
return buf;
@@ -873,7 +903,7 @@ export class Draft18Codec implements ControlCodec {
private decodeSubscribeOk(payload: Uint8Array): DecodedControlMessage {
let p = 0;
const alias = readVi64(payload, p); p += alias.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {})
p += props.bytesRead;
@@ -889,9 +919,9 @@ export class Draft18Codec implements ControlCodec {
// ── REQUEST_OK (response, §10.5 — no Request ID) ───────────────────
private encodeRequestOk(msg: RequestOk): Uint8Array {
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const props = trackPropsOf(msg);
- const params = encodeMessageParams18(typed);
+ const params = encodeMessageParams18(typed, this.paramRegistry);
const propBytes = encodeTrackProperties18(props);
const buf = new Uint8Array(params.length + propBytes.length);
buf.set(params, 0);
@@ -900,7 +930,7 @@ export class Draft18Codec implements ControlCodec {
}
private decodeRequestOk(payload: Uint8Array): DecodedControlMessage {
- const params = decodeMessageParams18(payload, 0, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, 0, this.paramRegistry);
// Track Properties are CONTEXT-dependent on a REQUEST_OK: valid for a
// TRACK_STATUS_OK, but not for PUBLISH_NAMESPACE / SUBSCRIBE_NAMESPACE /
// SUBSCRIBE_TRACKS responses. The codec is context-free, so it decodes them
@@ -989,7 +1019,7 @@ export class Draft18Codec implements ControlCodec {
// Standalone body uses vi64 Location fields (full uint64, no QUIC cap).
private encodeFetch(msg: Fetch): Uint8Array {
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const f = msg.fetch;
let bodyLen: number;
if (f.fetchType === 0x1) {
@@ -1009,7 +1039,7 @@ export class Draft18Codec implements ControlCodec {
vi64EncodingLength(msg.requestId) +
vi64EncodingLength(BigInt(f.fetchType)) +
bodyLen +
- messageParams18EncodingLength(typed);
+ messageParams18EncodingLength(typed, this.paramRegistry);
const buf = new Uint8Array(len);
let p = writeVi64(msg.requestId, buf, 0);
p += writeVi64(BigInt(f.fetchType), buf, p);
@@ -1022,7 +1052,7 @@ export class Draft18Codec implements ControlCodec {
p += writeVi64(f.joiningRequestId, buf, p);
p += writeVi64(f.joiningStart, buf, p);
}
- buf.set(encodeMessageParams18(typed), p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
return buf;
}
@@ -1057,7 +1087,7 @@ export class Draft18Codec implements ControlCodec {
throw new ProtocolViolationError(`FETCH: invalid Fetch Type 0x${ft.value.toString(16)}`);
}
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
if (p !== payload.length) {
throw new ProtocolViolationError('FETCH: unexpected trailing bytes after parameters');
@@ -1065,6 +1095,106 @@ export class Draft18Codec implements ControlCodec {
return { type: 'FETCH', requestId: rid.value, fetch, parameters: typedToParams(params.params) };
}
+ // ── draft-21 FETCH (§9.11): Request ID, Full Track Name, Parameters ──
+ // The range is a LOCATION_FILTER parameter. A StandaloneFetch's draft-18
+ // range maps onto it: the draft-18 End Location's Object is exclusive, with 0
+ // meaning the whole End Group, while the LOCATION_FILTER's EndObject is
+ // inclusive and omitted for a whole group. There is no Joining FETCH: a
+ // subscriber asks for the prefix with FILL_PARAMETERS on its SUBSCRIBE.
+
+ private encodeFetch21(msg: Fetch): Uint8Array {
+ const f = msg.fetch;
+ if (f.fetchType !== 0x1) {
+ throw new ProtocolViolationError('draft-21 has no Joining FETCH; request the prefix with FILL_PARAMETERS');
+ }
+ validateFullTrackName(f.trackNamespace, f.trackName, { allowEmptyNamespace: true });
+ const parameters = new Map(msg.parameters);
+ if (!parameters.has(LOCATION_FILTER_TYPE)) {
+ const { startLocation: start, endLocation: end } = f;
+ if (end.group < start.group) {
+ throw new ProtocolViolationError(`FETCH End Group ${end.group} < Start Group ${start.group}`);
+ }
+ const fields = [start.group, start.object, end.group - start.group];
+ if (end.object > 0n) fields.push(end.object - 1n);
+ parameters.set(LOCATION_FILTER_TYPE, [encodeLocationFilterFields(fields)]);
+ }
+ const typed = paramsToTyped(parameters, this.paramRegistry);
+ const len =
+ vi64EncodingLength(msg.requestId) +
+ vi64TupleLength(f.trackNamespace) +
+ vi64BytesLength(f.trackName) +
+ messageParams18EncodingLength(typed, this.paramRegistry);
+ const buf = new Uint8Array(len);
+ let p = writeVi64(msg.requestId, buf, 0);
+ p += writeVi64Tuple(f.trackNamespace, buf, p);
+ p += writeVi64Bytes(f.trackName, buf, p);
+ buf.set(encodeMessageParams18(typed, this.paramRegistry), p);
+ return buf;
+ }
+
+ /**
+ * Decodes a draft-21 FETCH into a StandaloneFetch. An absolute LOCATION_FILTER
+ * with an end maps back to the draft-18 range; a filter that depends on the
+ * Largest Object (one field, or two fields with no end) or no filter at all
+ * gets a start of {0, 0} or its StartGroup/StartObject and an open end
+ * ({2^64-1, 0}), and the LOCATION_FILTER stays in `parameters` for the
+ * receiver to resolve.
+ */
+ private decodeFetch21(payload: Uint8Array): DecodedControlMessage {
+ let p = 0;
+ const rid = readVi64(payload, p); p += rid.bytesRead;
+ const ns = readVi64Tuple(payload, p); p += ns.bytesRead;
+ const name = readVi64Bytes(payload, p); p += name.bytesRead;
+ validateFullTrackName(ns.value, name.value, { allowEmptyNamespace: true });
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
+ p += params.bytesRead;
+ if (p !== payload.length) {
+ throw new ProtocolViolationError('FETCH: unexpected trailing bytes after parameters');
+ }
+ const parameters = typedToParams(params.params);
+ const filter = parameters.get(LOCATION_FILTER_TYPE)?.[0];
+ let fields: bigint[] = [];
+ if (filter !== undefined) {
+ if (!(filter instanceof Uint8Array)) throw new ProtocolViolationError('FETCH: LOCATION_FILTER is not bytes');
+ try {
+ fields = decodeLocationFilterFields(filter);
+ } catch (e) {
+ throw new ProtocolViolationError(`FETCH: ${(e as Error).message}`);
+ }
+ }
+ const openEnd = { group: MAX_VI64, object: 0n };
+ let startLocation = { group: 0n, object: 0n };
+ let endLocation = openEnd;
+ if (fields.length === 2) {
+ startLocation = { group: fields[0]!, object: fields[1]! };
+ } else if (fields.length >= 3) {
+ startLocation = { group: fields[0]!, object: fields[1]! };
+ const endGroup = fields[0]! + fields[2]!;
+ endLocation = { group: endGroup, object: fields.length === 4 ? fields[3]! + 1n : 0n };
+ }
+ return {
+ type: 'FETCH',
+ requestId: rid.value,
+ fetch: { fetchType: 0x1, trackNamespace: ns.value, trackName: name.value, startLocation, endLocation },
+ parameters,
+ };
+ }
+
+ // ── draft-21 PUBLISH_STATE_NOTIFY (§9.10): Parameters only ──────────
+
+ private encodeParamsOnly(parameters: Parameters): Uint8Array {
+ const typed = paramsToTyped(parameters, this.paramRegistry);
+ return encodeMessageParams18(typed, this.paramRegistry);
+ }
+
+ private decodeParamsOnly(payload: Uint8Array, what: string): Parameters {
+ const params = decodeMessageParams18(payload, 0, this.paramRegistry);
+ if (params.bytesRead !== payload.length) {
+ throw new ProtocolViolationError(`${what}: unexpected trailing bytes after parameters`);
+ }
+ return typedToParams(params.params);
+ }
+
// ── FETCH_OK (response, §10.13 — no Request ID) ────────────────────
// Sent as the first message on the same bidi stream; the topology stamps the
// Request ID from the stream context. Track Properties (§2.5) trail the params.
@@ -1073,16 +1203,16 @@ export class Draft18Codec implements ControlCodec {
if (msg.endOfTrack !== 0 && msg.endOfTrack !== 1) {
throw new ProtocolViolationError(`FETCH_OK End Of Track must be 0 or 1, got ${msg.endOfTrack}`);
}
- const typed = paramsToTyped(msg.parameters);
+ const typed = paramsToTyped(msg.parameters, this.paramRegistry);
const len =
1 + vi64LocationLength(msg.endLocation) +
- messageParams18EncodingLength(typed) +
+ messageParams18EncodingLength(typed, this.paramRegistry) +
trackProperties18EncodingLength(trackPropsOf(msg));
const buf = new Uint8Array(len);
let p = 0;
buf[p++] = msg.endOfTrack;
p += writeVi64Location(msg.endLocation, buf, p);
- const params = encodeMessageParams18(typed);
+ const params = encodeMessageParams18(typed, this.paramRegistry);
buf.set(params, p); p += params.length;
buf.set(encodeTrackProperties18(trackPropsOf(msg)), p);
return buf;
@@ -1096,7 +1226,7 @@ export class Draft18Codec implements ControlCodec {
throw new ProtocolViolationError(`FETCH_OK End Of Track must be 0 or 1, got ${endOfTrack}`);
}
const end = readVi64Location(payload, p); p += end.bytesRead;
- const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY);
+ const params = decodeMessageParams18(payload, p, this.paramRegistry);
p += params.bytesRead;
const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {})
p += props.bytesRead;
diff --git a/packages/transport/src/control/draft21-codec.test.ts b/packages/transport/src/control/draft21-codec.test.ts
new file mode 100644
index 00000000..e4637e94
--- /dev/null
+++ b/packages/transport/src/control/draft21-codec.test.ts
@@ -0,0 +1,152 @@
+/**
+ * The draft-21 variant of the draft-18 control codec (`createControlCodec(21)`)
+ * and the draft-21 LOCATION_FILTER mapping of {@link SubscriptionFilter}.
+ */
+import { describe, it, expect } from 'vitest';
+import { createControlCodec } from './codec.js';
+import {
+ decodeLocationFilterFields, decodeSubscriptionFilter, encodeFillParameters, encodeSubscriptionFilter,
+ subscriptionWindow, validateSubscriptionFilter, windowContains, type SubscriptionFilter,
+} from './subscription-filter.js';
+import { bytesToHex } from '../vectors/load-vectors.js';
+import type { ControlMessage, Fetch, Goaway } from './messages.js';
+
+const codec18 = createControlCodec(18);
+const codec21 = createControlCodec(21);
+const enc = (s: string) => new TextEncoder().encode(s);
+
+describe('draft-21 FETCH', () => {
+ it('encodes a standalone range as a LOCATION_FILTER (the relay/moqxr vector)', () => {
+ const fetch: Fetch = {
+ type: 'FETCH',
+ requestId: 7n,
+ fetch: {
+ fetchType: 0x1, trackNamespace: [enc('live')], trackName: enc('video'),
+ startLocation: { group: 12n, object: 5n }, endLocation: { group: 15n, object: 10n },
+ },
+ parameters: new Map(),
+ };
+ expect(bytesToHex(codec21.encode(fetch))).toBe('1600140701046c69766505766964656f0121040c050309');
+ });
+
+ it('an End Object of 0 is the whole End Group: EndObject is omitted', () => {
+ const fetch: Fetch = {
+ type: 'FETCH', requestId: 1n,
+ fetch: {
+ fetchType: 0x1, trackNamespace: [enc('live')], trackName: enc('video'),
+ startLocation: { group: 2n, object: 0n }, endLocation: { group: 4n, object: 0n },
+ },
+ parameters: new Map(),
+ };
+ const decoded = codec21.decode(codec21.encode(fetch), 0).message as Fetch;
+ expect(decodeLocationFilterFields(decoded.parameters.get(0x21n)![0] as Uint8Array)).toEqual([2n, 0n, 2n]);
+ expect(decoded.fetch).toMatchObject({ startLocation: { group: 2n, object: 0n }, endLocation: { group: 4n, object: 0n } });
+ });
+
+ it('has no Joining FETCH', () => {
+ const joining: Fetch = {
+ type: 'FETCH', requestId: 1n,
+ fetch: { fetchType: 0x3, joiningRequestId: 0n, joiningStart: 0n },
+ parameters: new Map(),
+ };
+ expect(() => codec21.encode(joining)).toThrow(/FILL_PARAMETERS/);
+ expect(() => codec18.encode(joining)).not.toThrow();
+ });
+});
+
+describe('draft-21 GOAWAY and PUBLISH_STATE_NOTIFY', () => {
+ it('GOAWAY drops the Request ID', () => {
+ const goaway: Goaway = { type: 'GOAWAY', newSessionUri: '', timeout: 5000n, requestId: 3n };
+ expect(bytesToHex(codec21.encode(goaway))).toBe('100003009388');
+ expect((codec18.decode(codec18.encode(goaway), 0).message as Goaway).requestId).toBe(3n);
+ });
+
+ it('PUBLISH_STATE_NOTIFY exists only on draft 21', () => {
+ const notify = { type: 'PUBLISH_STATE_NOTIFY', parameters: new Map([[0x10n, [1n]]]) } as ControlMessage;
+ const bytes = codec21.encode(notify);
+ expect(codec21.decode(bytes, 0).message).toMatchObject({ type: 'PUBLISH_STATE_NOTIFY' });
+ expect(() => codec18.encode(notify)).toThrow();
+ expect(() => codec18.decode(bytes, 0)).toThrow();
+ });
+});
+
+describe('draft-21 LOCATION_FILTER mapping', () => {
+ const fields = (filter: SubscriptionFilter) => decodeLocationFilterFields(encodeSubscriptionFilter(filter, 21));
+
+ it('maps each filter to its draft-21 form', () => {
+ expect(fields({ type: 'NextGroupStart' })).toEqual([0n]);
+ expect(fields({ type: 'LargestObject' })).toEqual([0n, 0n]);
+ expect(fields({ type: 'RelativeStart', groups: 1n })).toEqual([1n]);
+ expect(fields({ type: 'AbsoluteStart', startGroup: 12n, startObject: 5n })).toEqual([12n, 5n]);
+ expect(fields({ type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n })).toEqual([12n, 5n, 3n]);
+ expect(fields({ type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n, endObject: 7n }))
+ .toEqual([12n, 5n, 3n, 7n]);
+ });
+
+ it('an open filter from {0, 0} is the zero-length "no filter", not the Next Object', () => {
+ expect(encodeSubscriptionFilter({ type: 'AbsoluteStart', startGroup: 0n, startObject: 0n }, 21).length).toBe(0);
+ });
+
+ it('decodes back to the same filter', () => {
+ const filters: SubscriptionFilter[] = [
+ { type: 'NextGroupStart' },
+ { type: 'LargestObject' },
+ { type: 'RelativeStart', groups: 3n },
+ { type: 'AbsoluteStart', startGroup: 12n, startObject: 5n },
+ { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n },
+ { type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n },
+ { type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n, endObject: 7n },
+ ];
+ for (const filter of filters) {
+ expect(decodeSubscriptionFilter(encodeSubscriptionFilter(filter, 21), 21)).toEqual(filter);
+ }
+ });
+
+ it('rejects malformed filters', () => {
+ expect(validateSubscriptionFilter(new Uint8Array([1, 2, 3, 4, 5]), 21)).toMatch(/more than four/);
+ expect(validateSubscriptionFilter(new Uint8Array([0x80]), 21)).toMatch(/truncated/); // vi64: a 2-byte value cut short
+ });
+
+ it('draft 21 forms have no draft-18 encoding', () => {
+ expect(() => encodeSubscriptionFilter({ type: 'RelativeStart', groups: 1n }, 18)).toThrow(RangeError);
+ expect(() => encodeSubscriptionFilter(
+ { type: 'AbsoluteRange', startGroup: 1n, startObject: 0n, endGroup: 2n, endObject: 3n }, 18)).toThrow(RangeError);
+ });
+
+ it('FILL_PARAMETERS nests a LOCATION_FILTER in a bare parameter sequence', () => {
+ expect(bytesToHex(encodeFillParameters({ type: 'RelativeStart', groups: 1n }))).toBe('210101');
+ expect(encodeFillParameters().length).toBe(0);
+ });
+});
+
+describe('subscription windows (re-applying a filter to a shared Track Alias, §3.1)', () => {
+ const L = { group: 5n, object: 3n };
+ const inWin = (filter: SubscriptionFilter | undefined, largest: typeof L | undefined, g: bigint, o: bigint) =>
+ windowContains(subscriptionWindow(filter, largest), g, o);
+
+ it('resolves the relative forms against Largest Object', () => {
+ expect(subscriptionWindow({ type: 'NextGroupStart' }, L)).toEqual({ start: { group: 6n, object: 0n } });
+ expect(subscriptionWindow({ type: 'LargestObject' }, L)).toEqual({ start: { group: 5n, object: 4n } });
+ expect(subscriptionWindow({ type: 'RelativeStart', groups: 1n }, L)).toEqual({ start: { group: 5n, object: 0n } });
+ expect(subscriptionWindow({ type: 'RelativeStart', groups: 9n }, L)).toEqual({ start: { group: 0n, object: 0n } });
+ });
+
+ it('with no Largest Object (empty track) every relative start is the beginning', () => {
+ expect(subscriptionWindow({ type: 'NextGroupStart' }, undefined)).toEqual({ start: { group: 0n, object: 0n } });
+ expect(subscriptionWindow({ type: 'LargestObject' }, undefined)).toEqual({ start: { group: 0n, object: 0n } });
+ });
+
+ it('checks start and inclusive end', () => {
+ expect(inWin(undefined, L, 0n, 0n)).toBe(true);
+ expect(inWin({ type: 'LargestObject' }, L, 5n, 3n)).toBe(false);
+ expect(inWin({ type: 'LargestObject' }, L, 5n, 4n)).toBe(true);
+ const range = { type: 'AbsoluteRange', startGroup: 2n, startObject: 1n, endGroup: 4n } as const;
+ expect(inWin(range, L, 2n, 0n)).toBe(false);
+ expect(inWin(range, L, 2n, 1n)).toBe(true);
+ expect(inWin(range, L, 4n, 999n)).toBe(true); // whole end group
+ expect(inWin(range, L, 5n, 0n)).toBe(false);
+ const withEndObject = { ...range, endObject: 7n };
+ expect(inWin(withEndObject, L, 4n, 7n)).toBe(true);
+ expect(inWin(withEndObject, L, 4n, 8n)).toBe(false);
+ });
+});
diff --git a/packages/transport/src/control/encoder.ts b/packages/transport/src/control/encoder.ts
index b01d30c3..9811dfec 100644
--- a/packages/transport/src/control/encoder.ts
+++ b/packages/transport/src/control/encoder.ts
@@ -199,6 +199,7 @@ function messageTypeCode(type: ControlMessage['type']): Varint {
// Draft-18-only types — not valid in the draft-14/16 encoder
case 'SUBSCRIBE_TRACKS':
case 'PUBLISH_BLOCKED':
+ case 'PUBLISH_STATE_NOTIFY':
throw new Error(`Cannot encode draft-18-only message type "${type}" with draft-16 encoder`);
}
}
@@ -403,6 +404,7 @@ function payloadLength(msg: ControlMessage): number {
// Draft-18-only types — unreachable (messageTypeCode throws first)
case 'SUBSCRIBE_TRACKS':
case 'PUBLISH_BLOCKED':
+ case 'PUBLISH_STATE_NOTIFY':
throw new Error(`Cannot encode draft-18-only message type "${msg.type}" with draft-16 encoder`);
}
}
@@ -557,6 +559,7 @@ function writePayload(msg: ControlMessage, buf: Uint8Array, offset: number): num
// Draft-18-only types — unreachable (messageTypeCode throws first)
case 'SUBSCRIBE_TRACKS':
case 'PUBLISH_BLOCKED':
+ case 'PUBLISH_STATE_NOTIFY':
throw new Error(`Cannot encode draft-18-only message type "${msg.type}" with draft-16 encoder`);
}
diff --git a/packages/transport/src/control/fill-parameters.ts b/packages/transport/src/control/fill-parameters.ts
new file mode 100644
index 00000000..83971b26
--- /dev/null
+++ b/packages/transport/src/control/fill-parameters.ts
@@ -0,0 +1,45 @@
+import { decodeParameterSequence, DRAFT21_MESSAGE_PARAM_REGISTRY, type MessageParams18 } from './message-params-18.js';
+import { decodeSubscriptionFilter, subscriptionWindow, type SubscriptionFilter, type SubscriptionWindow } from './subscription-filter.js';
+import type { GroupOrder } from '../data/types.js';
+import { ProtocolViolationError } from '../errors.js';
+
+/** Per-message overrides; omitted values inherit the subscription (9.20.16). */
+export interface FillOptions {
+ readonly filter?: SubscriptionFilter;
+ readonly groupOrder?: GroupOrder;
+}
+
+const allowed = new Set([0x0an, 0x20n, 0x21n, 0x22n, 0x25n, 0x26n, 0x27n, 0x28n]);
+const repeatable = new Set([0x25n, 0x26n, 0x27n, 0x28n]);
+
+export function decodeFillParameters(bytes: Uint8Array): FillOptions & { parameters: MessageParams18 } {
+ const parameters = decodeParameterSequence(bytes, DRAFT21_MESSAGE_PARAM_REGISTRY);
+ const result: { filter?: SubscriptionFilter; groupOrder?: GroupOrder; parameters: MessageParams18 } = { parameters };
+ for (const [type, values] of parameters) {
+ if (!allowed.has(type)) throw new ProtocolViolationError(`Parameter 0x${type.toString(16)} is not allowed in FILL_PARAMETERS (9.20.16)`);
+ if (!repeatable.has(type) && values.length > 1) throw new ProtocolViolationError(`Duplicate fill parameter 0x${type.toString(16)}`);
+ const value = values[0]!;
+ if (type === 0x21n && value.kind === 'bytes') result.filter = decodeSubscriptionFilter(value.value, 21);
+ if (type === 0x22n && value.kind === 'uint8') {
+ if (value.value !== 1 && value.value !== 2) throw new ProtocolViolationError('Fill GROUP_ORDER must be 1 or 2');
+ result.groupOrder = value.value === 2 ? 'descending' : 'ascending';
+ }
+ }
+ return result;
+}
+
+/** A fill is bounded by the Largest Object in its own response (3.4). */
+export function resolveFillWindow(
+ filter: SubscriptionFilter | undefined,
+ largest: { group: bigint; object: bigint } | undefined,
+): SubscriptionWindow | null {
+ if (!largest) return null;
+ const window = subscriptionWindow(filter, largest);
+ let end = largest;
+ if (window.end && (window.end.group < largest.group
+ || (window.end.group === largest.group && window.end.object !== undefined && window.end.object < largest.object))) {
+ end = { group: window.end.group, object: window.end.object ?? 0xffffffffffffffffn };
+ }
+ if (window.start.group > end.group || (window.start.group === end.group && window.start.object > end.object)) return null;
+ return { start: window.start, end };
+}
diff --git a/packages/transport/src/control/message-params-18.ts b/packages/transport/src/control/message-params-18.ts
index 96f639d9..6d42d275 100644
--- a/packages/transport/src/control/message-params-18.ts
+++ b/packages/transport/src/control/message-params-18.ts
@@ -21,7 +21,7 @@
import { readVi64, writeVi64, vi64EncodingLength, MAX_VI64 } from '../primitives/vi64.js';
import { readUint8, writeUint8, validateTrackNamespacePrefix } from '../primitives/bytes.js';
import { ProtocolViolationError } from '../errors.js';
-import { MessageParam18, type ParamValueKind } from './codes-18.js';
+import { MessageParam18, MessageParam21, type ParamValueKind } from './codes-18.js';
export type { ParamValueKind };
@@ -104,6 +104,11 @@ export const DEFAULT_MESSAGE_PARAM_REGISTRY: MessageParamRegistry = new Map(
Object.values(MessageParam18).map((d) => [BigInt(d.type), d.kind] as const),
);
+/** Registry built from the draft-21 message-parameter type table (draft-21 §16.7). */
+export const DRAFT21_MESSAGE_PARAM_REGISTRY: MessageParamRegistry = new Map(
+ Object.values(MessageParam21).map((d) => [BigInt(d.type), d.kind] as const),
+);
+
// ─── value length / write / read ─────────────────────────────────────
function paramValueLength(v: MessageParamValue): number {
@@ -281,14 +286,28 @@ export function decodeMessageParams18(
buf: Uint8Array,
offset: number,
registry: MessageParamRegistry,
+): { params: MessageParams18; bytesRead: number } {
+ const count = readVi64(buf, offset);
+ const decoded = readParameterSequence(buf, offset + count.bytesRead, registry, count.value);
+ return { params: decoded.params, bytesRead: count.bytesRead + decoded.bytesRead };
+}
+
+/** Decode a length-bounded parameter sequence without a count (draft-21 fills). */
+export function decodeParameterSequence(buf: Uint8Array, registry: MessageParamRegistry): MessageParams18 {
+ return readParameterSequence(buf, 0, registry).params;
+}
+
+function readParameterSequence(
+ buf: Uint8Array,
+ offset: number,
+ registry: MessageParamRegistry,
+ count?: bigint,
): { params: MessageParams18; bytesRead: number } {
let p = offset;
- const count = readVi64(buf, p);
- p += count.bytesRead;
const params = new Map();
let prevType = 0n;
- for (let i = 0n; i < count.value; i++) {
+ for (let i = 0n; count === undefined ? p < buf.length : i < count; i++) {
const delta = readVi64(buf, p);
p += delta.bytesRead;
const type = prevType + delta.value;
diff --git a/packages/transport/src/control/messages.ts b/packages/transport/src/control/messages.ts
index 86a96e2f..d46a460d 100644
--- a/packages/transport/src/control/messages.ts
+++ b/packages/transport/src/control/messages.ts
@@ -343,6 +343,17 @@ export interface Fetch {
readonly parameters: Parameters;
}
+/**
+ * draft-21 §9.10 PUBLISH_STATE_NOTIFY: a publisher's report, on a subscription's
+ * request stream, of LARGEST_OBJECT, FORWARD and the LOCATION_FILTER in effect.
+ * Parameters only; the topology stamps the Request ID from the stream context.
+ */
+export interface PublishStateNotify {
+ readonly type: 'PUBLISH_STATE_NOTIFY';
+ readonly requestId?: RequestId;
+ readonly parameters: Parameters;
+}
+
/** @see draft-ietf-moq-transport-16 §9.17 */
export interface FetchOk {
readonly type: 'FETCH_OK';
@@ -561,4 +572,5 @@ export type ControlMessage =
| PublishBlocked
| UnsubscribeNamespace
| PublishNamespaceOk
- | PublishNamespaceError;
+ | PublishNamespaceError
+ | PublishStateNotify;
diff --git a/packages/transport/src/control/subscription-filter.ts b/packages/transport/src/control/subscription-filter.ts
index d5c78dfe..f82c9018 100644
--- a/packages/transport/src/control/subscription-filter.ts
+++ b/packages/transport/src/control/subscription-filter.ts
@@ -21,6 +21,9 @@
import { varint, writeVarint, varintEncodingLength, readVarint } from '../primitives/varint.js';
import { readLocation } from '../primitives/location.js';
import { readVi64, writeVi64, vi64EncodingLength, MAX_VI64 } from '../primitives/vi64.js';
+import { isDraft21, isRequestStreamDraft } from '../versions.js';
+import { encodeMessageParams18, DRAFT21_MESSAGE_PARAM_REGISTRY, type MessageParamValue } from './message-params-18.js';
+import type { GroupOrder } from '../data/types.js';
/**
* Subscription filter — controls which objects pass through a subscription.
@@ -41,10 +44,25 @@ export type SubscriptionFilter =
| { readonly type: 'LatestObject' }
/** §5.1.2: Start at an explicit location (open-ended). */
| { readonly type: 'AbsoluteStart'; readonly startGroup: bigint; readonly startObject: bigint }
- /** §5.1.2: Explicit start and (absolute) end group. */
- | { readonly type: 'AbsoluteRange'; readonly startGroup: bigint; readonly startObject: bigint; readonly endGroup: bigint };
+ /**
+ * §5.1.2: Explicit start and (absolute) end group. `endObject` (draft 21 only)
+ * is the inclusive last object of the end group; without it the whole end
+ * group is included.
+ */
+ | {
+ readonly type: 'AbsoluteRange';
+ readonly startGroup: bigint;
+ readonly startObject: bigint;
+ readonly endGroup: bigint;
+ readonly endObject?: bigint;
+ }
+ /**
+ * draft-21 §9.20.10 only: start `groups` groups back from the Next Group
+ * (1 = the current group), open-ended. The one-field LOCATION_FILTER.
+ */
+ | { readonly type: 'RelativeStart'; readonly groups: bigint };
-const FILTER_TYPE: Record = {
+const FILTER_TYPE: Record, bigint> = {
NextGroupStart: 0x1n,
LargestObject: 0x2n,
LatestObject: 0x2n, // deprecated alias
@@ -52,6 +70,131 @@ const FILTER_TYPE: Record = {
AbsoluteRange: 0x4n,
};
+// ─── draft-21 LOCATION_FILTER (§9.20.10) ─────────────────────────────
+
+/** A LOCATION_FILTER carries at most StartGroup, StartObject, EndGroupDelta, EndObject. */
+const MAX_LOCATION_FIELDS = 4;
+
+/** Encode 0-4 LOCATION_FILTER fields: bare vi64s, the count implied by the length. */
+export function encodeLocationFilterFields(fields: readonly bigint[]): Uint8Array {
+ if (fields.length > MAX_LOCATION_FIELDS) {
+ throw new RangeError(`LOCATION_FILTER has ${fields.length} fields; at most ${MAX_LOCATION_FIELDS}`);
+ }
+ let size = 0;
+ for (const f of fields) size += vi64EncodingLength(f);
+ const buf = new Uint8Array(size);
+ let offset = 0;
+ for (const f of fields) offset += writeVi64(f, buf, offset);
+ return buf;
+}
+
+/**
+ * Decode LOCATION_FILTER fields. The Length decides how many are present; more
+ * than four, a truncated field, or StartGroup + EndGroupDelta above 2^64-1 is
+ * malformed.
+ * @throws {RangeError} on malformed bytes.
+ */
+export function decodeLocationFilterFields(bytes: Uint8Array): bigint[] {
+ const reason = validateLocationFilter(bytes);
+ if (reason !== undefined) throw new RangeError(`decodeLocationFilterFields: ${reason}`);
+ const fields: bigint[] = [];
+ let pos = 0;
+ while (pos < bytes.length) {
+ const r = readVi64(bytes, pos);
+ fields.push(r.value);
+ pos += r.bytesRead;
+ }
+ return fields;
+}
+
+function validateLocationFilter(bytes: Uint8Array): string | undefined {
+ const fields: bigint[] = [];
+ let pos = 0;
+ while (pos < bytes.length) {
+ if (fields.length === MAX_LOCATION_FIELDS) return 'LOCATION_FILTER has more than four fields';
+ try {
+ const r = readVi64(bytes, pos);
+ fields.push(r.value);
+ pos += r.bytesRead;
+ } catch {
+ return 'LOCATION_FILTER has a truncated field';
+ }
+ }
+ if (fields.length >= 3 && fields[0]! + fields[2]! > MAX_VI64) {
+ return `LOCATION_FILTER overflows uint64: StartGroup ${fields[0]} + EndGroupDelta ${fields[2]}`;
+ }
+ return undefined;
+}
+
+/** The LOCATION_FILTER fields for a {@link SubscriptionFilter} (draft 21). */
+function locationFilterFields(filter: SubscriptionFilter): bigint[] {
+ switch (filter.type) {
+ case 'NextGroupStart':
+ return [0n];
+ case 'LargestObject':
+ case 'LatestObject':
+ return [0n, 0n]; // the Next Object
+ case 'RelativeStart':
+ if (filter.groups < 1n) throw new RangeError(`RelativeStart groups ${filter.groups} < 1 (0 is NextGroupStart)`);
+ return [filter.groups];
+ case 'AbsoluteStart':
+ // {0, 0} as two fields would mean the Next Object; an open filter from the
+ // start of the track is the zero-length "no filter" instead.
+ return filter.startGroup === 0n && filter.startObject === 0n ? [] : [filter.startGroup, filter.startObject];
+ case 'AbsoluteRange': {
+ if (filter.endGroup < filter.startGroup) {
+ throw new RangeError(`AbsoluteRange endGroup ${filter.endGroup} < startGroup ${filter.startGroup}`);
+ }
+ if (filter.endGroup > MAX_VI64) {
+ throw new RangeError(`AbsoluteRange endGroup ${filter.endGroup} exceeds 2^64-1`);
+ }
+ const fields = [filter.startGroup, filter.startObject, filter.endGroup - filter.startGroup];
+ if (filter.endObject !== undefined) fields.push(filter.endObject);
+ return fields;
+ }
+ }
+}
+
+/** The {@link SubscriptionFilter} a draft-21 LOCATION_FILTER expresses. */
+function filterFromLocationFields(fields: readonly bigint[]): SubscriptionFilter {
+ switch (fields.length) {
+ case 0:
+ return { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n };
+ case 1:
+ return fields[0] === 0n ? { type: 'NextGroupStart' } : { type: 'RelativeStart', groups: fields[0]! };
+ case 2:
+ return fields[0] === 0n && fields[1] === 0n
+ ? { type: 'LargestObject' }
+ : { type: 'AbsoluteStart', startGroup: fields[0]!, startObject: fields[1]! };
+ default: {
+ const range = {
+ type: 'AbsoluteRange' as const,
+ startGroup: fields[0]!,
+ startObject: fields[1]!,
+ endGroup: fields[0]! + fields[2]!,
+ };
+ return fields.length === 4 ? { ...range, endObject: fields[3]! } : range;
+ }
+ }
+}
+
+/**
+ * The FILL_PARAMETERS value (draft-21 §9.20.16) for a fill over `filter`: a bare
+ * parameter sequence (no count, bounded by the Length) holding its
+ * LOCATION_FILTER. An omitted filter inherits the subscription's filter.
+ */
+export function encodeFillParameters(filter?: SubscriptionFilter, groupOrder?: GroupOrder): Uint8Array {
+ const params = new Map();
+ if (filter !== undefined) params.set(0x21n, [{ kind: 'bytes', value: encodeLocationFilterFields(locationFilterFields(filter)) }]);
+ if (groupOrder !== undefined) {
+ if (groupOrder !== 'ascending' && groupOrder !== 'descending') throw new RangeError('Invalid fill Group Order');
+ params.set(0x22n, [{ kind: 'uint8', value: groupOrder === 'descending' ? 2 : 1 }]);
+ }
+ const block = encodeMessageParams18(params, DRAFT21_MESSAGE_PARAM_REGISTRY);
+ // The enclosing FILL_PARAMETERS length replaces the message parameter count.
+ return block.slice(vi64EncodingLength(BigInt(params.size)));
+}
+
/**
* Encode a {@link SubscriptionFilter} into the inner wire bytes of a
* SUBSCRIPTION_FILTER parameter (the message-parameter codec adds the outer
@@ -63,10 +206,18 @@ const FILTER_TYPE: Record = {
* valid but `Start Group + Delta` overflows uint64).
*/
export function encodeSubscriptionFilter(filter: SubscriptionFilter, draftVersion: number): Uint8Array {
+ // draft-21 §9.20.10: the 0x21 parameter is a LOCATION_FILTER.
+ if (isDraft21(draftVersion)) return encodeLocationFilterFields(locationFilterFields(filter));
+ if (filter.type === 'RelativeStart') {
+ throw new RangeError(`RelativeStart has no draft-${draftVersion} SUBSCRIPTION_FILTER form`);
+ }
+ if (filter.type === 'AbsoluteRange' && filter.endObject !== undefined) {
+ throw new RangeError(`AbsoluteRange endObject has no draft-${draftVersion} SUBSCRIPTION_FILTER form`);
+ }
const filterType = FILTER_TYPE[filter.type];
// draft-18 §5.1.2: vi64 internals; AbsoluteRange carries an End Group DELTA.
- if (draftVersion === 18) {
+ if (isRequestStreamDraft(draftVersion)) {
let endGroupDelta = 0n;
if (filter.type === 'AbsoluteRange') {
if (filter.endGroup < filter.startGroup) {
@@ -139,8 +290,9 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number
if (reason !== undefined) {
throw new RangeError(`decodeSubscriptionFilter: ${reason}`);
}
+ if (isDraft21(draftVersion)) return filterFromLocationFields(decodeLocationFilterFields(bytes));
- const read = draftVersion === 18
+ const read = isRequestStreamDraft(draftVersion)
? (pos: number) => readVi64(bytes, pos)
: (pos: number) => {
const { value, bytesRead } = readVarint(bytes, pos);
@@ -165,7 +317,7 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number
// AbsoluteRange (0x4): draft-18 carries an End Group DELTA, 14/16 the absolute value.
const e = read(pos);
- const endGroup = draftVersion === 18 ? g.value + e.value : e.value;
+ const endGroup = isRequestStreamDraft(draftVersion) ? g.value + e.value : e.value;
return { type: 'AbsoluteRange', startGroup: g.value, startObject: o.value, endGroup };
}
@@ -175,7 +327,8 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number
* The caller maps a returned reason to a PROTOCOL_VIOLATION close.
*/
export function validateSubscriptionFilter(bytes: Uint8Array, draftVersion: number): string | undefined {
- return draftVersion === 18
+ if (isDraft21(draftVersion)) return validateLocationFilter(bytes);
+ return isRequestStreamDraft(draftVersion)
? validateSubscriptionFilter18(bytes)
: validateSubscriptionFilterLegacy(bytes);
}
@@ -307,3 +460,57 @@ function validateSubscriptionFilter18(bytes: Uint8Array): string | undefined {
return undefined;
}
+
+// ─── Subscription windows (draft-21 §3.1 shared Track Aliases) ────────
+
+/** The absolute Locations a subscription's filter selects. `end` is inclusive; without `end.object` the whole end group. */
+export interface SubscriptionWindow {
+ readonly start: { readonly group: bigint; readonly object: bigint };
+ readonly end?: { readonly group: bigint; readonly object?: bigint };
+}
+
+/**
+ * Resolve a subscription's Location filter against the Largest Object it was
+ * established with (SUBSCRIBE_OK's LARGEST_OBJECT; `undefined` for an empty
+ * track). A subscriber sharing one Track Alias across several subscriptions to
+ * the same track re-applies each window to attribute an object (draft-21 §3.1).
+ * No filter selects the whole track.
+ */
+export function subscriptionWindow(
+ filter: SubscriptionFilter | undefined,
+ largest: { readonly group: bigint; readonly object: bigint } | undefined,
+): SubscriptionWindow {
+ const origin = { group: 0n, object: 0n };
+ switch (filter?.type) {
+ case undefined:
+ return { start: origin };
+ case 'NextGroupStart':
+ return { start: largest ? { group: largest.group < MAX_VI64 ? largest.group + 1n : MAX_VI64, object: 0n } : origin };
+ case 'LargestObject':
+ case 'LatestObject':
+ return { start: largest ? { group: largest.group, object: largest.object + 1n } : origin };
+ case 'RelativeStart': {
+ if (!largest) return { start: origin };
+ const group = largest.group + 1n - filter.groups;
+ return { start: { group: group < 0n ? 0n : group > MAX_VI64 ? MAX_VI64 : group, object: 0n } };
+ }
+ case 'AbsoluteStart':
+ return { start: { group: filter.startGroup, object: filter.startObject } };
+ case 'AbsoluteRange':
+ return {
+ start: { group: filter.startGroup, object: filter.startObject },
+ end: filter.endObject === undefined
+ ? { group: filter.endGroup }
+ : { group: filter.endGroup, object: filter.endObject },
+ };
+ }
+}
+
+/** Whether Location {group, object} falls inside `window`. */
+export function windowContains(window: SubscriptionWindow, group: bigint, object: bigint): boolean {
+ const { start, end } = window;
+ if (group < start.group || (group === start.group && object < start.object)) return false;
+ if (end === undefined) return true;
+ if (group > end.group) return false;
+ return group < end.group || end.object === undefined || object <= end.object;
+}
diff --git a/packages/transport/src/data/data-codec-18.ts b/packages/transport/src/data/data-codec-18.ts
index abeae3a1..f6e8117b 100644
--- a/packages/transport/src/data/data-codec-18.ts
+++ b/packages/transport/src/data/data-codec-18.ts
@@ -22,9 +22,9 @@ import {
import { classifyStream18, classifyDatagram18 } from './stream-type-18.js';
/** Create the draft-18 {@link DataCodec}. */
-export function createDataCodec18(): DataCodec {
+export function createDataCodec18(version: 18 | 21 = 18): DataCodec {
return {
- version: 18,
+ version,
decodeSubgroupHeader: (buf, offset) => decodeSubgroupHeader18(buf, offset),
@@ -38,7 +38,7 @@ export function createDataCodec18(): DataCodec {
},
decodeFetchObject18: (buf, offset, prior, isFirstObject, groupOrder) =>
- decodeFetchObject18(buf, offset, prior, isFirstObject, groupOrder),
+ decodeFetchObject18(buf, offset, prior, isFirstObject, groupOrder, version),
decodeObjectDatagram: (buf, offset) => decodeObjectDatagram18(buf, offset),
diff --git a/packages/transport/src/data/data-codec.ts b/packages/transport/src/data/data-codec.ts
index e170a723..ae5883ff 100644
--- a/packages/transport/src/data/data-codec.ts
+++ b/packages/transport/src/data/data-codec.ts
@@ -116,8 +116,8 @@ export interface DataCodec {
* @throws {Error} for draft versions without a wired data codec.
*/
export function createDataCodec(version: DraftVersion = 16): DataCodec {
- if (version === 18) {
- return createDataCodec18();
+ if (version === 18 || version === 21) {
+ return createDataCodec18(version);
}
if (!isWiredDraft(version)) {
throw new Error(`DataCodec: draft-${version} data plane is not yet implemented`);
diff --git a/packages/transport/src/data/decoder-18.ts b/packages/transport/src/data/decoder-18.ts
index 3494b79b..8e12e7ca 100644
--- a/packages/transport/src/data/decoder-18.ts
+++ b/packages/transport/src/data/decoder-18.ts
@@ -305,17 +305,25 @@ export function decodeFetchHeader18(
return { header: { requestId: rid.value }, bytesRead: pos - offset };
}
-/** Whether a Serialization Flags value is an End-of-Range marker. */
-function isEndOfRange18(flags: bigint): boolean {
- return flags === BigInt(FetchSpecialFlags.END_NON_EXISTENT) || flags === BigInt(FetchSpecialFlags.END_UNKNOWN);
+/** draft-21 §11.4.4.2: End of Timed-Out Range, the objects' delivery timeout expired. */
+const END_TIMED_OUT_21 = 0x20cn;
+
+/** Whether a Serialization Flags value is an End-of-Range marker on `version`. */
+function isEndOfRange18(flags: bigint, version: 18 | 21 = 18): boolean {
+ return flags === BigInt(FetchSpecialFlags.END_NON_EXISTENT) || flags === BigInt(FetchSpecialFlags.END_UNKNOWN)
+ || (version === 21 && flags === END_TIMED_OUT_21);
}
-/** Validate Serialization Flags: 0x00–0x7F, 0x8C, or 0x10C; else PROTOCOL_VIOLATION. */
-function validateFetchFlags18(flags: bigint): void {
- if (isEndOfRange18(flags)) return;
+/**
+ * Validate Serialization Flags: 0x00–0x7F, 0x8C, 0x10C, and on draft 21 0x20C;
+ * else PROTOCOL_VIOLATION.
+ */
+function validateFetchFlags18(flags: bigint, version: 18 | 21 = 18): void {
+ if (isEndOfRange18(flags, version)) return;
if (flags >= 0n && flags <= 0x7fn) return;
throw new ProtocolViolationError(
- `Invalid draft-18 fetch Serialization Flags 0x${flags.toString(16)} (allowed: 0x00-0x7F, 0x8C, 0x10C)`,
+ `Invalid draft-${version} fetch Serialization Flags 0x${flags.toString(16)} (allowed: 0x00-0x7F, 0x8C, 0x10C`
+ + `${version === 21 ? ', 0x20C' : ''})`,
);
}
@@ -334,21 +342,26 @@ export function decodeFetchObject18(
prior: FetchObjectPrior18 | undefined,
isFirstObject: boolean,
groupOrder: GroupOrder,
+ version: 18 | 21 = 18,
): { item: DecodedFetchItem; bytesRead: number; nextPrior: FetchObjectPrior18 } {
let pos = offset;
const fl = readVi64(buf, pos); pos += fl.bytesRead;
const flags = fl.value;
- validateFetchFlags18(flags);
+ validateFetchFlags18(flags, version);
// ── End-of-Range marker (§11.4.4.2) ──────────────────────────────────
- if (isEndOfRange18(flags)) {
+ // A draft-21 End of Timed-Out Range (0x20C) is, like 0x10C, a range of unknown
+ // status: the objects may exist but will not be delivered.
+ if (isEndOfRange18(flags, version)) {
const gid = readVi64(buf, pos); pos += gid.bytesRead;
const oid = readVi64(buf, pos); pos += oid.bytesRead;
- const pl = readVi64(buf, pos); pos += pl.bytesRead;
- // §11.4.4.2: an End-of-Range marker carries no Object Payload — the always-
- // present Object Payload Length MUST be 0.
- if (pl.value !== 0n) {
- throw new ProtocolViolationError(`End-of-Range marker has a non-zero payload length (${pl.value})`);
+ // Keep the established draft-18 framing separate from the draft-21
+ // marker-only form. Never guess the form from the following byte.
+ if (version === 18) {
+ const pl = readVi64(buf, pos); pos += pl.bytesRead;
+ if (pl.value !== 0n) {
+ throw new ProtocolViolationError(`End-of-Range marker has a non-zero payload length (${pl.value})`);
+ }
}
const item: FetchEndOfRange = {
flags: varint(flags),
diff --git a/packages/transport/src/data/encoder-18.test.ts b/packages/transport/src/data/encoder-18.test.ts
index 4adaf74b..5953c99e 100644
--- a/packages/transport/src/data/encoder-18.test.ts
+++ b/packages/transport/src/data/encoder-18.test.ts
@@ -84,6 +84,20 @@ describe('encodeObjectDatagram18 ↔ decodeObjectDatagram18', () => {
});
import { encodeFetchHeader18, encodeFetchObject18, encodeFetchEndOfRange18 } from './encoder-18.js';
+
+describe('End-of-Range draft isolation', () => {
+ it.each([18, 21] as const)('pins the draft-%s marker bytes', (draft) => {
+ const expected = draft === 18 ? [0x80, 0x8c, 5, 10, 0] : [0x80, 0x8c, 5, 10];
+ const { bytes } = encodeFetchEndOfRange18(true, 5n, 10n, undefined, draft);
+ expect(bytes).toEqual(new Uint8Array(expected));
+ const wire = new Uint8Array([...expected, 0x1c, 1, 0, 7, 1, 0xa3]);
+ const first = decodeFetchObject18(wire, 0, undefined, true, 'ascending', draft);
+ expect(first.bytesRead).toBe(expected.length);
+ const next = decodeFetchObject18(wire, first.bytesRead, first.nextPrior, false, 'ascending', draft);
+ expect(next.item).toMatchObject({ groupId: 7n, objectId: 0n, payload: new Uint8Array([0xa3]) });
+ expect(first.bytesRead + next.bytesRead).toBe(wire.length);
+ });
+});
import { decodeFetchHeader18, decodeFetchObject18, type FetchObjectPrior18 } from './decoder-18.js';
import type { FetchObject, FetchEndOfRange } from './types.js';
diff --git a/packages/transport/src/data/encoder-18.ts b/packages/transport/src/data/encoder-18.ts
index ca4489a4..73e5b00d 100644
--- a/packages/transport/src/data/encoder-18.ts
+++ b/packages/transport/src/data/encoder-18.ts
@@ -261,23 +261,25 @@ export function encodeFetchObject18(
}
/**
- * Encode a draft-18 End-of-Range marker (§11.4.4.2): Serialization Flags 0x8C
- * (non-existent) or 0x10C (unknown), Group ID, Object ID, and a zero Payload
- * Length. Position prior advances to the marker; field priors are unchanged.
+ * Encode an End-of-Range marker: Serialization Flags 0x8C (non-existent) or
+ * 0x10C (unknown), Group ID and Object ID. Draft 18 retains its zero Payload
+ * Length field; the draft-21 form omits it. Position prior advances to the
+ * marker; field priors are unchanged.
*/
export function encodeFetchEndOfRange18(
nonExistent: boolean,
groupId: bigint,
objectId: bigint,
prior: FetchObjectPrior18 | undefined,
+ version: 18 | 21 = 18,
): { bytes: Uint8Array; nextPrior: FetchObjectPrior18 } {
const flags = BigInt(nonExistent ? FetchSpecialFlags.END_NON_EXISTENT : FetchSpecialFlags.END_UNKNOWN);
- const size = vi64EncodingLength(flags) + vi64EncodingLength(groupId) + vi64EncodingLength(objectId) + vi64EncodingLength(0n);
+ const size = vi64EncodingLength(flags) + vi64EncodingLength(groupId) + vi64EncodingLength(objectId) + (version === 18 ? 1 : 0);
const buf = new Uint8Array(size);
let p = writeVi64(flags, buf, 0);
p += writeVi64(groupId, buf, p);
p += writeVi64(objectId, buf, p);
- writeVi64(0n, buf, p); // §11.4.4.2: payload length MUST be 0
+ if (version === 18) writeVi64(0n, buf, p);
const nextPrior: FetchObjectPrior18 = {
groupId, objectId,
lastObjectSubgroupId: prior?.lastObjectSubgroupId,
diff --git a/packages/transport/src/data/types.ts b/packages/transport/src/data/types.ts
index e7806333..76c2135f 100644
--- a/packages/transport/src/data/types.ts
+++ b/packages/transport/src/data/types.ts
@@ -146,7 +146,12 @@ export interface ObjectDatagram {
*/
export type DataStreamHeader =
| { readonly type: 'subgroup'; readonly header: SubgroupHeader }
- | { readonly type: 'fetch'; readonly header: FetchHeader };
+ /**
+ * `fill` (draft 21 §3.4): a fill fetch stream, whose FETCH_HEADER carries the
+ * Request ID of the SUBSCRIBE (or REQUEST_UPDATE) that asked for the fill. It
+ * has no FETCH_OK: a FIN completes it, a reset fails it.
+ */
+ | { readonly type: 'fetch'; readonly header: FetchHeader; readonly fill?: boolean };
/**
* Discriminated union for objects delivered via subscription.
diff --git a/packages/transport/src/errors.ts b/packages/transport/src/errors.ts
index 86675aac..5d80decf 100644
--- a/packages/transport/src/errors.ts
+++ b/packages/transport/src/errors.ts
@@ -32,6 +32,8 @@ export const SessionError = {
EXPIRED_AUTH_TOKEN: varint(0x18),
INVALID_AUTHORITY: varint(0x19),
MALFORMED_AUTHORITY: varint(0x1a),
+ /** draft-21: more than MAX_REQUEST_UPDATES updates outstanding on one request. */
+ TOO_MANY_REQUEST_UPDATES: varint(0x1b),
} as const;
/**
@@ -126,6 +128,31 @@ export const RequestError18 = {
REDIRECT: varint(0x34),
} as const;
+/**
+ * REQUEST_ERROR codes — **draft-21** (§12.3): draft 18's without
+ * DUPLICATE_SUBSCRIPTION and INVALID_JOINING_REQUEST_ID, plus the filter codes.
+ */
+export const RequestError21 = {
+ INTERNAL_ERROR: varint(0x0),
+ UNAUTHORIZED: varint(0x1),
+ TIMEOUT: varint(0x2),
+ NOT_SUPPORTED: varint(0x3),
+ MALFORMED_AUTH_TOKEN: varint(0x4),
+ EXPIRED_AUTH_TOKEN: varint(0x5),
+ GOING_AWAY: varint(0x6),
+ EXCESSIVE_LOAD: varint(0x9),
+ DOES_NOT_EXIST: varint(0x10),
+ INVALID_RANGE: varint(0x11),
+ MALFORMED_TRACK: varint(0x12),
+ UNINTERESTED: varint(0x20),
+ PREFIX_OVERLAP: varint(0x30),
+ NAMESPACE_TOO_LARGE: varint(0x31),
+ UNSUPPORTED_EXTENSION: varint(0x33),
+ REDIRECT: varint(0x34),
+ CONFLICTING_FILTERS: varint(0x35),
+ INVALID_FILTER: varint(0x36),
+} as const;
+
/**
* PUBLISH_DONE Status Codes — **draft-18** (§15.10.3). Differs from legacy:
* TOO_FAR_BEHIND = 0x5 and EXPIRED = 0x6 (swapped), plus EXCESSIVE_LOAD (0x9).
diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts
index 12e56a63..951a5532 100644
--- a/packages/transport/src/index.ts
+++ b/packages/transport/src/index.ts
@@ -16,6 +16,7 @@ export {
ControlMessageType18,
SetupOption18,
MessageParam18,
+ MessageParam21,
PropertyRange18,
isMandatoryProperty,
} from './control/codes-18.js';
@@ -39,12 +40,15 @@ export {
decodeMessageParams18,
messageParams18EncodingLength,
DEFAULT_MESSAGE_PARAM_REGISTRY,
+ DRAFT21_MESSAGE_PARAM_REGISTRY,
} from './control/message-params-18.js';
export type {
MessageParamValue,
MessageParams18,
MessageParamRegistry,
} from './control/message-params-18.js';
+export { decodeFillParameters, resolveFillWindow } from './control/fill-parameters.js';
+export type { FillOptions } from './control/fill-parameters.js';
export {
encodeTrackProperties18,
decodeTrackProperties18,
@@ -81,7 +85,7 @@ export { readReasonPhrase, writeReasonPhrase, reasonPhraseEncodingLength } from
// ─── Error Codes ─────────────────────────────────────────────────────
export { SessionError, RequestError, PublishDoneCode, DataStreamError, ProtocolViolationError } from './errors.js';
// draft-18 error code registries (canonical; the legacy exports above stay draft-14/16).
-export { RequestError18, PublishDoneCode18, StreamResetCode18, DataStreamError18 } from './errors.js';
+export { RequestError18, RequestError21, PublishDoneCode18, StreamResetCode18, DataStreamError18 } from './errors.js';
// ─── Control Messages ────────────────────────────────────────────────
export { MessageType } from './control/codes.js';
@@ -125,6 +129,7 @@ export type {
SubscribeNamespace,
SubscribeTracks,
PublishBlocked,
+ PublishStateNotify,
UnsubscribeNamespace,
PublishNamespaceOk,
PublishNamespaceError,
@@ -143,7 +148,7 @@ export { createControlCodec } from './control/codec.js';
export type { ControlCodec, DraftVersion, DecodedControlMessage } from './control/codec.js';
// ─── Profile (per-draft behavior bundle) ─────────────────────────────
-export { isWiredDraft, WIRED_DRAFTS } from './versions.js';
+export { isDraft21, isRequestStreamDraft, isWiredDraft, WIRED_DRAFTS } from './versions.js';
export { getProtocolProfile } from './profile.js';
export type { ProtocolProfile, ProfileCapabilities } from './profile.js';
export { getRequestPolicy } from './session/request-policy.js';
@@ -323,5 +328,12 @@ export { Session } from './session/session.js';
export { SessionError as SessionProtocolError, SessionDrainingError } from './session/session.js';
export type { SetupOptions, SubscribeOptions, RequestUpdateOptions, FetchOptions, JoiningFetchOptions, FetchAcceptOptions, TrackStatusAcceptOptions, RequestResult } from './session/session.js';
export { resolveJoiningFetchRange, type JoiningFetchFields } from './session/joining.js';
-export { decodeSubscriptionFilter } from './control/subscription-filter.js';
-export type { SubscriptionFilter } from './control/subscription-filter.js';
+export {
+ decodeSubscriptionFilter,
+ encodeFillParameters,
+ encodeLocationFilterFields,
+ decodeLocationFilterFields,
+ subscriptionWindow,
+ windowContains,
+} from './control/subscription-filter.js';
+export type { SubscriptionFilter, SubscriptionWindow } from './control/subscription-filter.js';
diff --git a/packages/transport/src/profile.test.ts b/packages/transport/src/profile.test.ts
index e4cbb54e..dc7a4576 100644
--- a/packages/transport/src/profile.test.ts
+++ b/packages/transport/src/profile.test.ts
@@ -9,7 +9,7 @@ import { createDataCodec } from './data/data-codec.js';
import { getRequestPolicy } from './session/request-policy.js';
describe('getProtocolProfile', () => {
- for (const version of [14, 16, 18] as const) {
+ for (const version of [14, 16, 18, 21] as const) {
it(`bundles control + data + policy for draft-${version}`, () => {
const profile = getProtocolProfile(version);
expect(profile.version).toBe(version);
@@ -67,3 +67,10 @@ describe('createDataCodec — version selection', () => {
expect(createDataCodec(18).version).toBe(18);
});
});
+
+describe('draft 21', () => {
+ it('shares the draft-18 stream model capabilities and request policy', () => {
+ expect(getProtocolProfile(21).capabilities).toEqual(getProtocolProfile(18).capabilities);
+ expect(getRequestPolicy(21)).toEqual(getRequestPolicy(18));
+ });
+});
diff --git a/packages/transport/src/profile.ts b/packages/transport/src/profile.ts
index 08754132..f4eaf9b5 100644
--- a/packages/transport/src/profile.ts
+++ b/packages/transport/src/profile.ts
@@ -25,6 +25,7 @@ import { createControlCodec, type ControlCodec } from './control/codec.js';
import { createDataCodec, type DataCodec } from './data/data-codec.js';
import { getRequestPolicy, type RequestPolicy } from './session/request-policy.js';
import type { DraftVersion } from './versions.js';
+import { isRequestStreamDraft } from './versions.js';
/**
* Coarse, session/adapter-facing per-draft semantic facts.
@@ -97,7 +98,7 @@ const D18_CAPABILITIES: ProfileCapabilities = {
/** Resolve the {@link ProfileCapabilities} for a draft version. */
function getCapabilities(version: DraftVersion): ProfileCapabilities {
- return version === 18 ? D18_CAPABILITIES : LEGACY_CAPABILITIES;
+ return isRequestStreamDraft(version) ? D18_CAPABILITIES : LEGACY_CAPABILITIES;
}
/**
diff --git a/packages/transport/src/session/boundary.test.ts b/packages/transport/src/session/boundary.test.ts
index 75c73d7a..b63581a8 100644
--- a/packages/transport/src/session/boundary.test.ts
+++ b/packages/transport/src/session/boundary.test.ts
@@ -63,10 +63,11 @@ const FORBIDDEN_IMPORT_SUBSTRINGS = [
*
* `control/codes-18.js` is a code-point enum table; `SetupOption18` is a set of
* well-known setup-option codes (PATH, AUTHORITY, …) the SetupGate negotiates
- * by name. That is semantic, not serialization. Keep this map tiny + justified.
+ * by name, and `SetupOption21` extends it with the draft-21 options
+ * (MAX_FILTER_RANGES, MAX_REQUEST_UPDATES). That is semantic, not serialization. Keep this map tiny + justified.
*/
const NAME_SPECIFIC_ALLOW: Readonly>> = {
- '../control/codes-18.js': ['SetupOption18'],
+ '../control/codes-18.js': ['SetupOption18', 'SetupOption21'],
};
/** Raw draft-18 wire details that must never appear in session source code. */
diff --git a/packages/transport/src/session/request-policy.ts b/packages/transport/src/session/request-policy.ts
index bb47fbab..f2761a3f 100644
--- a/packages/transport/src/session/request-policy.ts
+++ b/packages/transport/src/session/request-policy.ts
@@ -20,6 +20,7 @@
*/
import type { DraftVersion } from '../versions.js';
+import { isRequestStreamDraft } from '../versions.js';
/** How a receiver validates an inbound Request ID. */
export type InboundValidation = 'strict-sequence' | 'parity-and-duplicate';
@@ -51,5 +52,5 @@ const STREAM_LIMITED: RequestPolicy = {
* @param version Draft version (default: 16).
*/
export function getRequestPolicy(version: DraftVersion = 16): RequestPolicy {
- return version === 18 ? STREAM_LIMITED : CREDITED;
+ return isRequestStreamDraft(version) ? STREAM_LIMITED : CREDITED;
}
diff --git a/packages/transport/src/session/session-d21-conformance.test.ts b/packages/transport/src/session/session-d21-conformance.test.ts
new file mode 100644
index 00000000..d306e72c
--- /dev/null
+++ b/packages/transport/src/session/session-d21-conformance.test.ts
@@ -0,0 +1,410 @@
+import { describe, expect, it } from 'vitest';
+import { Session } from './session.js';
+import { EndpointRole, SessionState } from './types.js';
+import { varint } from '../primitives/varint.js';
+import type { Parameters } from '../control/messages.js';
+import { decodeSubscriptionFilter, subscriptionWindow } from '../control/subscription-filter.js';
+
+const namespace = [new TextEncoder().encode('live')];
+const name = new TextEncoder().encode('video');
+
+describe('draft-21 SUBSCRIBE_TRACKS Forward updates (9.20.19)', () => {
+ it.each(['unknown', 'pending', 'outside prefix', 'cancelled', 'older draft'] as const)(
+ 'refuses a PUBLISH association with %s before allocating a request', (condition) => {
+ const session = established(condition === 'older draft' ? 18 : 21);
+ if (condition !== 'unknown') {
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n,
+ trackNamespacePrefix: namespace, parameters: new Map() });
+ if (condition !== 'pending') session.acceptSubscribeTracks(1n);
+ if (condition === 'cancelled') session.handleInboundSubscribeTracksClosed(1n);
+ }
+ const prefix = condition === 'outside prefix' ? [new TextEncoder().encode('other')] : namespace;
+ expect(() => session.publish(prefix, name, 10n, { subscribeTracksRequestId: 1n })).toThrow(/active matching draft-21/);
+ expect(session.publish(prefix, name, 10n).requestId).toBe(0n);
+ },
+ );
+ it.each([0n, 1n])('preserves independent PUBLISH Forward=%s inside a subscribed prefix', (forward) => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n,
+ trackNamespacePrefix: namespace, parameters: new Map([[0x10n, [1n - forward]]]) });
+ session.acceptSubscribeTracks(1n);
+ const pub = session.publish(namespace, name, 10n, { parameters: new Map([[0x10n, [forward]]]) });
+ expect(session.getOutgoingPublish(pub.requestId)!.forwardState).toBe(Number(forward));
+ const action = pub.actions.find((a) => a.type === 'send_control')!;
+ if (action.type !== 'send_control' || action.message.type !== 'PUBLISH') throw new Error('missing publish');
+ expect(action.message.parameters.get(0x10n)).toEqual([forward]);
+ });
+ it('leaves the previous Forward State intact when an outgoing update is rejected', () => {
+ const session = established();
+ const id = session.subscribeTracks(namespace).requestId;
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() });
+ const update = session.requestUpdate(id, { forward: 0 });
+ session.handleControlMessage({ type: 'REQUEST_ERROR', requestId: update.requestId,
+ errorCode: 16n, retryInterval: 0n, errorReason: 'rejected' });
+ expect(session.getTrackSubscription(id)).toHaveProperty('forward', 1);
+ });
+
+ it('does not apply Forward when a combined prefix update overlaps another request', () => {
+ const session = established();
+ const other = [new TextEncoder().encode('other')];
+ for (const [id, prefix] of [[1n, namespace], [3n, other]] as const) {
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: id,
+ trackNamespacePrefix: prefix, parameters: new Map() });
+ session.acceptSubscribeTracks(id);
+ }
+ const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 5n,
+ existingRequestId: 1n, parameters: new Map([[0x34n, [other]], [0x10n, [0n]]]) });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control',
+ message: expect.objectContaining({ type: 'REQUEST_ERROR' }) }));
+ expect(session.getIncomingTrackSubscription(1n)).toMatchObject({ trackNamespacePrefix: namespace, forward: 1 });
+ });
+
+ it.each([0n, 1n])('retains initial Forward=%s and omission leaves it unchanged', (forward) => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n,
+ trackNamespacePrefix: namespace, parameters: new Map([[0x10n, [forward]]]) });
+ session.acceptSubscribeTracks(1n);
+ session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n,
+ parameters: new Map([[0x34n, [namespace]]]) });
+ expect(session.getIncomingTrackSubscription(1n)).toHaveProperty('forward', Number(forward));
+ });
+
+ it('rejects an invalid incoming Forward before applying the prefix', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n,
+ trackNamespacePrefix: namespace, parameters: new Map() });
+ session.acceptSubscribeTracks(1n);
+ const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n,
+ existingRequestId: 1n, parameters: new Map([[0x34n, [[]]], [0x10n, [2n]]]) });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n }));
+ });
+
+ it('does not allocate a request for an out-of-scope draft-18 Forward update', () => {
+ const session = established(18);
+ const id = session.subscribeTracks(namespace).requestId;
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() });
+ expect(() => session.requestUpdate(id, { forward: 0, trackNamespacePrefix: namespace })).toThrow(/draft-21/);
+ expect(session.requestUpdate(id, { trackNamespacePrefix: namespace }).requestId).toBe(2n);
+ });
+
+ it.each([false, true])('emits Forward with a prefix change=%s and commits only on acknowledgment', (withPrefix) => {
+ const session = established();
+ const id = session.subscribeTracks(namespace).requestId;
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() });
+ const update = session.requestUpdate(id, { forward: 0, ...(withPrefix ? { trackNamespacePrefix: namespace } : {}) });
+ expect(update.actions).toContainEqual(expect.objectContaining({
+ type: 'send_control', message: expect.objectContaining({ parameters: expect.any(Map) }),
+ }));
+ const action = update.actions.find((a) => a.type === 'send_control')!;
+ if (action.type !== 'send_control' || action.message.type !== 'REQUEST_UPDATE') throw new Error('missing update');
+ expect(action.message.parameters.get(0x10n)).toEqual([0n]);
+ expect(session.getTrackSubscription(id)).toHaveProperty('forward', 1);
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: update.requestId, parameters: new Map() });
+ expect(session.getTrackSubscription(id)).toHaveProperty('forward', 0);
+ });
+
+ it('applies Forward to future matching PUBLISHes, not existing or unrelated subscriptions', () => {
+ const session = established();
+ const id = varint(1n);
+ session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: id, trackNamespacePrefix: namespace, parameters: new Map() });
+ session.acceptSubscribeTracks(id);
+ const first = session.publish(namespace, name, 10n, { subscribeTracksRequestId: id });
+ session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: id, parameters: new Map([[0x10n, [0n]]]) });
+ expect(session.getIncomingTrackSubscription(id)).toHaveProperty('forward', 0);
+ expect(session.getOutgoingPublish(first.requestId)!.forwardState).toBe(1);
+ const next = session.publish(namespace, name, 11n, { subscribeTracksRequestId: id });
+ expect(session.getOutgoingPublish(next.requestId)!.forwardState).toBe(0);
+ const action = next.actions.find((a) => a.type === 'send_control')!;
+ if (action.type !== 'send_control' || action.message.type !== 'PUBLISH') throw new Error('missing publish');
+ expect(action.message.parameters.get(0x10n)).toEqual([0n]);
+ const unrelated = session.publish([new TextEncoder().encode('other')], name, 12n);
+ expect(session.getOutgoingPublish(unrelated.requestId)!.forwardState).toBe(1);
+ });
+});
+
+function established(draft: 18 | 21 = 21): Session {
+ const session = new Session(EndpointRole.CLIENT, draft);
+ session.initiateSetup();
+ session.handleControlMessage({ type: 'SETUP', setupOptions: new Map() });
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ return session;
+}
+
+describe('draft-21 fill parameter scope (9.20.16)', () => {
+ it.each([0, 1] as const)('evaluates a pipelined fill after an earlier Forward=%s update', (forward) => {
+ const session = established();
+ const id = session.subscribe(namespace, name, { forward: forward === 0 ? 1 : 0 }).requestId;
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() });
+ const first = session.requestUpdate(id, { forward });
+ const second = session.requestUpdate(id, { fill: {} });
+ const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]);
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters });
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: second.requestId, parameters });
+ expect(session.expectedFillsOf(id)).toEqual(forward === 1 ? [second.requestId] : []);
+ });
+
+ it('a pipelined fill inherits the preceding accepted filter update', () => {
+ const session = established();
+ const id = session.subscribe(namespace, name).requestId;
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() });
+ const first = session.requestUpdate(id, { subscriptionFilter: { type: 'NextGroupStart' } });
+ const second = session.requestUpdate(id, { fill: {} });
+ const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]);
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters });
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: second.requestId, parameters });
+ expect(session.expectedFillsOf(id)).toEqual([]);
+ });
+ it.each(['empty track', 'empty range'] as const)('does not retain an expected fill for an %s', (kind) => {
+ const session = established();
+ const { requestId } = session.subscribe(namespace, name, { fill: {}, subscriptionFilter: { type: 'NextGroupStart' } });
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n,
+ parameters: kind === 'empty track' ? new Map() : new Map([[0x09n, [{ group: 5n, object: 0n }]]]),
+ trackProperties: new Map(),
+ });
+ expect(session.expectedFillsOf(requestId)).toEqual([]);
+ expect(session.getSubscription(requestId)!.state).toBe('established');
+ });
+ it.each(['rejected', 'cancelled', 'closed'] as const)('reclaims a %s subscription fill', (terminal) => {
+ const session = established();
+ const { requestId } = session.subscribe(namespace, name, { fill: {} });
+ if (terminal === 'rejected') session.handleControlMessage({ type: 'REQUEST_ERROR', requestId, errorCode: 16n, retryInterval: 0n, errorReason: 'gone' });
+ else if (terminal === 'cancelled') session.unsubscribe(requestId);
+ else session.close();
+ expect(session.fillSubscriptionFor(requestId)).toBeUndefined();
+ });
+ it.each([
+ { description: 'FORWARD is not a fill parameter', bytes: [0x10, 1] },
+ { description: 'recursive FILL_PARAMETERS is forbidden', bytes: [0x23, 0] },
+ { description: 'GROUP_ORDER must be 1 or 2', bytes: [0x22, 3] },
+ { description: 'duplicate GROUP_ORDER is forbidden', bytes: [0x22, 1, 0, 2] },
+ { description: 'truncated GROUP_ORDER is malformed', bytes: [0x22] },
+ { description: 'LOCATION_FILTER has at most four fields', bytes: [0x21, 5, 0, 0, 0, 0, 0] },
+ ])('$description', ({ bytes }) => {
+ const session = established();
+ const actions = session.handleControlMessage({
+ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name,
+ parameters: new Map([[0x23n, [new Uint8Array(bytes)]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n }));
+ expect(session.getIncomingSubscription(1n)).toBeUndefined();
+ });
+
+ it('allows an independent nested GROUP_ORDER and LOCATION_FILTER', () => {
+ const session = established();
+ const parameters: Parameters = new Map([
+ [0x21n, [new Uint8Array([0])]],
+ [0x22n, [1n]],
+ [0x23n, [new Uint8Array([0x21, 0, 1, 2])]],
+ ]);
+ expect(session.handleControlMessage({
+ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters,
+ })).toEqual([]);
+ expect(session.getIncomingSubscription(1n)).toBeDefined();
+ });
+
+ it('does not allocate a subscription when a local fill option is invalid', () => {
+ const session = established(18);
+ expect(() => session.subscribe(namespace, name, { fill: {} })).toThrow(/draft 21/);
+ expect(session.getSubscription(0n)).toBeUndefined();
+ expect(session.subscribe(namespace, name).requestId).toBe(0n);
+ });
+});
+
+describe('draft-21 filter updates', () => {
+ it.each([{ type: 'NextGroupStart' as const }, { type: 'RelativeStart' as const, groups: 0n }])('clamps a relative group above uint64: $type', (filter) => {
+ const max = 0xffffffffffffffffn;
+ expect(subscriptionWindow(filter, { group: max, object: 0n }).start).toEqual({ group: max, object: 0n });
+ });
+
+ it('does not consume a Request ID for an invalid local update filter', () => {
+ const session = established();
+ const { requestId } = session.subscribe(namespace, name);
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() });
+ expect(() => session.requestUpdate(requestId, {
+ subscriptionFilter: { type: 'AbsoluteStart', startGroup: -1n, startObject: 0n },
+ })).toThrow();
+ expect(session.requestUpdate(requestId, { forward: 0 }).requestId).toBe(2n);
+ });
+
+ it('rejects Track Property Filter on an individual subscription update', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace, trackName: name, parameters: new Map() });
+ session.acceptSubscribe(1n, 4n);
+ const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n,
+ parameters: new Map([[0x29n, [new Uint8Array([0, 2, 0, 0])]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n }));
+ });
+
+ it('rejects FILL_PARAMETERS on a namespace publication update', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'PUBLISH_NAMESPACE', requestId: 1n, trackNamespace: namespace, parameters: new Map() });
+ const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n,
+ parameters: new Map([[0x23n, [new Uint8Array()]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n }));
+ });
+ it.each([0x25n, 0x26n, 0x27n, 0x28n])('rejects unadvertised range filter %s with INVALID_FILTER', (type) => {
+ const session = established();
+ const actions = session.handleControlMessage({
+ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name,
+ parameters: new Map([[type, [new Uint8Array([0, 0])]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({
+ type: 'REQUEST_ERROR', requestId: 1n, errorCode: 0x36n,
+ }) }));
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ expect(session.getIncomingSubscription(1n)).toBeUndefined();
+ });
+
+ it('rejects an unadvertised Track Property Filter on SUBSCRIBE_TRACKS', () => {
+ const session = established();
+ const actions = session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: varint(1n),
+ trackNamespacePrefix: namespace, parameters: new Map([[0x29n, [new Uint8Array([0, 2, 0, 0])]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({
+ type: 'REQUEST_ERROR', errorCode: 0x36n,
+ }) }));
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('enforces MAX_FILTER_RANGES inside a fill without changing the subscription on rejection', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters: new Map() });
+ session.acceptSubscribe(1n, 4n);
+ session.setLargestLocationProvider(() => null);
+ const actions = session.handleControlMessage({
+ type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: 1n,
+ parameters: new Map([[0x10n, [0n]], [0x23n, [new Uint8Array([0x25, 2, 0, 0])]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({
+ type: 'REQUEST_ERROR', requestId: 3n, errorCode: 0x36n,
+ }) }));
+ expect(session.getIncomingSubscription(1n)!.forwardState).toBe(1);
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('reports the current Largest Object on a filter-only update', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters: new Map() });
+ session.acceptSubscribe(1n, 4n, { parameters: new Map([[0x09n, [{ group: 1n, object: 0n }]]]) });
+ session.setLargestLocationProvider(() => ({ group: 7n, object: 2n }));
+ const actions = session.handleControlMessage({
+ type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: 1n,
+ parameters: new Map([[0x21n, [new Uint8Array([0])]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({
+ type: 'REQUEST_OK', parameters: new Map([[0x09n, [{ group: 7n, object: 2n }]]]),
+ }) }));
+ expect(session.getIncomingSubscription(1n)!.locationWindow).toEqual({ start: { group: 8n, object: 0n } });
+ });
+ it('keeps the committed filter until REQUEST_OK and ignores a rejected replacement', () => {
+ const session = established();
+ const initial = { type: 'AbsoluteRange' as const, startGroup: 1n, startObject: 0n, endGroup: 2n };
+ const replacement = { ...initial, endGroup: 9n };
+ const { requestId } = session.subscribe(namespace, name, { subscriptionFilter: initial });
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackExtensions: new Map() });
+ const update = session.requestUpdate(requestId, { subscriptionFilter: replacement });
+ expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(initial);
+ session.handleControlMessage({ type: 'REQUEST_ERROR', requestId: update.requestId, errorCode: 0n, errorReason: 'no', retryInterval: varint(0n) });
+ expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(initial);
+ const retry = session.requestUpdate(requestId, { subscriptionFilter: replacement });
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: retry.requestId, parameters: new Map() });
+ expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(replacement);
+ });
+
+ it('does not move an existing relative filter when an update omits it', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace, trackName: name,
+ parameters: new Map([[0x21n, [new Uint8Array([0])]]]),
+ });
+ session.acceptSubscribe(1n, 4n, { parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]) });
+ session.setLargestLocationProvider(() => ({ group: 9n, object: 0n }));
+ session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n,
+ parameters: new Map([[0x10n, [0n]]]),
+ });
+ expect(session.getIncomingSubscription(1n)!.locationWindow).toEqual({ start: { group: 6n, object: 0n } });
+ });
+});
+
+describe('draft-21 PUBLISH_STATE_NOTIFY', () => {
+ it.each(['subscribe', 'publish'] as const)('preserves full-width Locations for %s-initiated subscriptions', (origin) => {
+ const session = established();
+ let requestId: bigint;
+ if (origin === 'subscribe') {
+ requestId = session.subscribe(namespace, name).requestId;
+ session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackExtensions: new Map() });
+ } else {
+ requestId = 1n;
+ session.handleControlMessage({ type: 'PUBLISH', requestId, trackAlias: 4n,
+ trackNamespace: namespace, trackName: name, parameters: new Map(), trackProperties: new Map(),
+ });
+ session.acceptSubscribe(requestId, 4n);
+ }
+ const largest = { group: 0xffffffffffffffffn, object: 0xfffffffffffffffen };
+ expect(session.handleControlMessage({ type: 'PUBLISH_STATE_NOTIFY', requestId, parameters: new Map([[0x09n, [largest]]]) })).toEqual([]);
+ const sub = session.getSubscription(requestId) ?? session.getIncomingSubscription(requestId);
+ expect(sub!.largestLocation).toEqual({ groupId: largest.group, objectId: largest.object });
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ });
+});
+
+describe('draft-21 parameter contracts', () => {
+ it('retains the initial PUBLISH Location Filter and requested Group Order', () => {
+ const session = established();
+ session.handleControlMessage({ type: 'PUBLISH', requestId: 1n, trackAlias: 4n,
+ trackNamespace: namespace, trackName: name,
+ parameters: new Map([[0x21n, [new Uint8Array([2, 1])]], [0x22n, [2n]]]), trackProperties: new Map(),
+ });
+ session.acceptSubscribe(1n, 4n);
+ const sub = session.getIncomingSubscription(1n)!;
+ expect(sub.locationWindow).toEqual({ start: { group: 2n, object: 1n } });
+ expect(sub.groupOrder).toBe('descending');
+ });
+
+ it('refuses to publish two different tracks on the same alias before allocating a request', () => {
+ const session = established();
+ session.publish(namespace, name, 4n);
+ const other = new TextEncoder().encode('audio');
+ expect(() => session.publish(namespace, other, 4n)).toThrow(/different track/);
+ expect(session.publish(namespace, other, 5n).requestId).toBe(2n);
+ });
+
+ it('refuses acceptance on another live track alias without establishing the request', () => {
+ const session = established();
+ session.publish(namespace, name, 4n);
+ session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace,
+ trackName: new TextEncoder().encode('audio'), parameters: new Map(),
+ });
+ expect(() => session.acceptSubscribe(1n, 4n)).toThrow(/different track/);
+ expect(session.getIncomingSubscription(1n)!.state).toBe('pending');
+ });
+ it('keeps PUBLISH Forward=0 on both sides of its parameterless acceptance', () => {
+ const publisher = established();
+ const { requestId } = publisher.publish(namespace, name, 4n, { parameters: new Map([[0x10n, [0n]]]) });
+ publisher.handleControlMessage({ type: 'REQUEST_OK', requestId, parameters: new Map() });
+ expect(publisher.getOutgoingPublish(requestId)!.forwardState).toBe(0);
+
+ const subscriber = established();
+ subscriber.handleControlMessage({ type: 'PUBLISH', requestId: 1n, trackAlias: 4n,
+ trackNamespace: namespace, trackName: name, parameters: new Map([[0x10n, [0n]]]), trackProperties: new Map(),
+ });
+ subscriber.acceptSubscribe(1n, 4n);
+ expect(subscriber.getIncomingSubscription(1n)!.forwardState).toBe(0);
+ });
+
+ it('rejects an invalid INCLUDE_PROPERTIES value', () => {
+ const session = established();
+ const actions = session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n,
+ trackNamespace: namespace, trackName: name, parameters: new Map([[0x35n, [2n]]]),
+ });
+ expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n }));
+ });
+
+ it('accepts EXPIRES on PUBLISH_NAMESPACE_OK', () => {
+ const session = established();
+ const { requestId } = session.publishNamespace(namespace);
+ expect(session.handleControlMessage({ type: 'REQUEST_OK', requestId, parameters: new Map([[0x08n, [1000n]]]) })).toEqual([]);
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ });
+});
diff --git a/packages/transport/src/session/session-d21.test.ts b/packages/transport/src/session/session-d21.test.ts
new file mode 100644
index 00000000..2139bb94
--- /dev/null
+++ b/packages/transport/src/session/session-d21.test.ts
@@ -0,0 +1,197 @@
+/**
+ * Session rules for draft 21: the draft-18 stream model with draft-21 parameter
+ * scopes, FILL_PARAMETERS on SUBSCRIBE, GOAWAY without a Request ID and
+ * PUBLISH_STATE_NOTIFY.
+ */
+import { describe, it, expect } from 'vitest';
+import { Session } from './session.js';
+import { EndpointRole, SessionState, type SendControlAction, type CloseConnectionAction } from './types.js';
+import { varint } from '../primitives/varint.js';
+import { SessionError as SessionErrorCode } from '../errors.js';
+import { bytesToHex } from '../vectors/load-vectors.js';
+import type { ControlMessage, Goaway, PublishStateNotify, Setup, Subscribe, SubscribeOk } from '../control/messages.js';
+
+const NS = [new TextEncoder().encode('live')];
+const NAME = new TextEncoder().encode('catalog');
+const FILL_PARAMETERS = 0x23n;
+
+function established(version: 18 | 21): Session {
+ const session = new Session(EndpointRole.CLIENT, version);
+ session.initiateSetup();
+ session.handleControlMessage({ type: 'SETUP', setupOptions: new Map() } as Setup);
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ return session;
+}
+
+function closeOf(actions: ReturnType): CloseConnectionAction | undefined {
+ return actions.find((a) => a.type === 'close_connection') as CloseConnectionAction | undefined;
+}
+
+function subscribed(session: Session): bigint {
+ const { requestId } = session.subscribe(NS, NAME);
+ session.handleControlMessage({
+ type: 'SUBSCRIBE_OK', requestId, trackAlias: varint(4n), parameters: new Map(), trackExtensions: [],
+ } as SubscribeOk);
+ return requestId;
+}
+
+describe('draft-21 SUBSCRIBE with a fill', () => {
+ it('sends FILL_PARAMETERS with the fill Location filter and marks the subscription', () => {
+ const session = established(21);
+ const { requestId, actions } = session.subscribe(NS, NAME, {
+ subscriptionFilter: { type: 'LargestObject' },
+ fill: { filter: { type: 'RelativeStart', groups: 1n } },
+ });
+ const msg = (actions[0] as SendControlAction).message as Subscribe;
+ expect(bytesToHex(msg.parameters.get(FILL_PARAMETERS)![0] as Uint8Array)).toBe('210101');
+ expect(bytesToHex(msg.parameters.get(0x21n)![0] as Uint8Array)).toBe('0000');
+ expect(session.getSubscription(requestId)!.fillRequested).toBe(true);
+ });
+
+ it('is refused before draft 21', () => {
+ const session = established(18);
+ expect(() => session.subscribe(NS, NAME, { fill: {} })).toThrow(/draft 21/);
+ });
+});
+
+describe('draft-21 GOAWAY', () => {
+ it('needs no Request ID', () => {
+ const session = established(21);
+ const actions = session.handleControlMessage({ type: 'GOAWAY', newSessionUri: '', timeout: 5000n } as Goaway);
+ expect(closeOf(actions)).toBeUndefined();
+ expect(session.state).toBe(SessionState.DRAINING);
+ });
+
+ it('draft 18 still requires one', () => {
+ const session = established(18);
+ const actions = session.handleControlMessage({ type: 'GOAWAY', newSessionUri: '', timeout: 5000n } as Goaway);
+ expect(closeOf(actions)?.reason).toMatch(/missing the Request ID/);
+ });
+});
+
+describe('draft-21 PUBLISH_STATE_NOTIFY', () => {
+ it('advances the subscription Largest Location', () => {
+ const session = established(21);
+ const requestId = subscribed(session);
+ const notify: PublishStateNotify = {
+ type: 'PUBLISH_STATE_NOTIFY', requestId,
+ parameters: new Map([[0x09n, [{ group: 3n, object: 7n }]], [0x10n, [0n]]]),
+ };
+ const actions = session.handleControlMessage(notify as ControlMessage);
+ expect(closeOf(actions)).toBeUndefined();
+ expect(session.getSubscription(requestId)!.largestLocation).toEqual({ groupId: 3n, objectId: 7n });
+ });
+
+ it('a parameter out of its scope is a PROTOCOL_VIOLATION', () => {
+ const session = established(21);
+ const requestId = subscribed(session);
+ const notify: PublishStateNotify = {
+ type: 'PUBLISH_STATE_NOTIFY', requestId, parameters: new Map([[0x08n, [10n]]]), // EXPIRES
+ };
+ expect(closeOf(session.handleControlMessage(notify as ControlMessage))?.reason).toMatch(/out of scope/);
+ });
+
+ it('one that crosses our cancellation is ignored', () => {
+ const session = established(21);
+ const notify: PublishStateNotify = { type: 'PUBLISH_STATE_NOTIFY', requestId: 99n, parameters: new Map() };
+ expect(session.handleControlMessage(notify as ControlMessage)).toEqual([]);
+ });
+});
+
+describe('draft-21 parameter scopes', () => {
+ it('SUBSCRIBE_OK may carry EXPIRES and LARGEST_OBJECT', () => {
+ const session = established(21);
+ const { requestId } = session.subscribe(NS, NAME);
+ const actions = session.handleControlMessage({
+ type: 'SUBSCRIBE_OK', requestId, trackAlias: varint(4n),
+ parameters: new Map([[0x08n, [0n]], [0x09n, [{ group: 1n, object: 2n }]]]),
+ trackExtensions: [],
+ } as SubscribeOk);
+ expect(closeOf(actions)).toBeUndefined();
+ });
+});
+
+const MAX_REQUEST_UPDATES = 0x08n;
+const TOO_MANY_REQUEST_UPDATES = 0x1bn;
+
+function establishedWithPeerOptions(version: 18 | 21, setupOptions: Map): Session {
+ const session = new Session(EndpointRole.CLIENT, version);
+ session.initiateSetup();
+ session.handleControlMessage({ type: 'SETUP', setupOptions } as unknown as Setup);
+ expect(session.state).toBe(SessionState.ESTABLISHED);
+ return session;
+}
+
+describe('draft-21 MAX_REQUEST_UPDATES (§9.1.7)', () => {
+ it('advertises our limit on draft 21 only', () => {
+ const s21 = new Session(EndpointRole.CLIENT, 21);
+ const setup21 = (s21.initiateSetup({ maxRequestUpdates: 2n })[0] as SendControlAction).message as Setup;
+ expect(setup21.setupOptions.get(MAX_REQUEST_UPDATES)).toEqual([2n]);
+ const s18 = new Session(EndpointRole.CLIENT, 18);
+ const setup18 = (s18.initiateSetup({ maxRequestUpdates: 2n })[0] as SendControlAction).message as Setup;
+ expect(setup18.setupOptions.has(MAX_REQUEST_UPDATES)).toBe(false);
+ });
+
+ it('never exceeds the peer limit of outstanding updates on one request', () => {
+ const session = establishedWithPeerOptions(21, new Map([[MAX_REQUEST_UPDATES, [1n]]]));
+ const requestId = subscribed(session);
+ const first = session.requestUpdate(requestId, { forward: 0 });
+ expect(() => session.requestUpdate(requestId, { forward: 1 })).toThrow(/MAX_REQUEST_UPDATES/);
+ // The response restores the credit.
+ session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters: new Map() } as unknown as ControlMessage);
+ expect(() => session.requestUpdate(requestId, { forward: 1 })).not.toThrow();
+ });
+
+ it('a limit of 0, or none, does not limit', () => {
+ const session = establishedWithPeerOptions(21, new Map());
+ const requestId = subscribed(session);
+ for (let i = 0; i < 5; i++) session.requestUpdate(requestId, { forward: 0 });
+ });
+
+ it('reports our advertised limit so the receiver can enforce it', () => {
+ const session = new Session(EndpointRole.CLIENT, 21);
+ session.initiateSetup({ maxRequestUpdates: 3n });
+ expect(session.ownMaxRequestUpdates).toBe(3n);
+ expect(SessionErrorCode.TOO_MANY_REQUEST_UPDATES).toBe(TOO_MANY_REQUEST_UPDATES);
+ });
+});
+
+describe('draft-21 fill on REQUEST_UPDATE (§3.4)', () => {
+ it('carries FILL_PARAMETERS and expects a fill stream under the update Request ID', () => {
+ const session = established(21);
+ const requestId = subscribed(session);
+ const { requestId: updateId, actions } = session.requestUpdate(requestId, {
+ fill: { filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n } },
+ });
+ const msg = (actions[0] as SendControlAction).message as ControlMessage & { parameters: Map };
+ expect(msg.parameters.has(FILL_PARAMETERS)).toBe(true);
+ expect(session.fillSubscriptionFor(updateId)?.requestId).toBe(requestId);
+ expect(session.takeFill(updateId)?.requestId).toBe(requestId);
+ expect(session.fillSubscriptionFor(updateId)).toBeUndefined();
+ });
+
+ it('a refused update opens no fill', () => {
+ const session = established(21);
+ const requestId = subscribed(session);
+ const { requestId: updateId } = session.requestUpdate(requestId, { fill: {} });
+ session.handleControlMessage({
+ type: 'REQUEST_ERROR', requestId: updateId, errorCode: 0x0n, errorReason: 'no', retryInterval: 0n,
+ } as unknown as ControlMessage);
+ expect(session.fillSubscriptionFor(updateId)).toBeUndefined();
+ });
+
+ it('is refused before draft 21', () => {
+ const session = established(18);
+ const requestId = subscribed(session);
+ expect(() => session.requestUpdate(requestId, { fill: {} })).toThrow(/draft 21/);
+ });
+
+ it('the SUBSCRIBE fill is tracked the same way', () => {
+ const session = established(21);
+ const { requestId } = session.subscribe(NS, NAME, { fill: {} });
+ expect(session.fillSubscriptionFor(requestId)?.requestId).toBe(requestId);
+ expect(session.cancelFillRequest(requestId)).toBe(true);
+ expect(session.getSubscription(requestId)!.fillRequested).toBe(false);
+ expect(session.cancelFillRequest(requestId)).toBe(false);
+ });
+});
diff --git a/packages/transport/src/session/session.ts b/packages/transport/src/session/session.ts
index 0c2252d0..eafc40b9 100644
--- a/packages/transport/src/session/session.ts
+++ b/packages/transport/src/session/session.ts
@@ -19,8 +19,11 @@ import { readLocation } from '../primitives/location.js';
* encode; resume-provider results above it are unencodable, so they fail
* closed (kept local: the sans-I/O session imports no wire modules). */
const MAX_VI64 = 18446744073709551615n;
-import { encodeSubscriptionFilter, validateSubscriptionFilter, decodeSubscriptionFilter, type SubscriptionFilter } from '../control/subscription-filter.js';
+import {
+ encodeFillParameters, encodeSubscriptionFilter, validateSubscriptionFilter, decodeSubscriptionFilter, subscriptionWindow, type SubscriptionFilter,
+} from '../control/subscription-filter.js';
import { resolveJoiningFetchRange } from './joining.js';
+import { decodeFillParameters, resolveFillWindow, type FillOptions } from '../control/fill-parameters.js';
import { validateTrackNamespace, validateTrackNamespacePrefix, validateFullTrackName, isReservedSessionNamespace, isReservedDotNamespace } from '../primitives/bytes.js';
import { SessionError as SessionErrorCode, RequestError as RequestErrorCode } from '../errors.js';
import type {
@@ -42,6 +45,7 @@ import type {
FetchOk,
FetchCancel,
PublishDone,
+ PublishStateNotify,
Unsubscribe,
Goaway,
MaxRequestId,
@@ -87,6 +91,7 @@ import { MessageParam } from '../control/parameters.js';
import type { Parameters, ParameterValue, TrackProperties } from '../control/messages.js';
import { AuthTokenCache, AuthCacheError } from './auth-cache.js';
import { AliasType, parseAuthorizationToken, parseAuthorizationToken18, type AuthorizationToken, type ResolvedToken } from '../control/auth-token.js';
+import { isDraft21, isRequestStreamDraft } from '../versions.js';
/**
* Set of known message parameter type codes.
@@ -203,6 +208,61 @@ const VALID_PARAMS_FOR_MESSAGE_TYPE_18: Map> = new Map([
[MessageParam.TRACK_NAMESPACE_PREFIX as bigint, new Set(['REQUEST_UPDATE'])],
]);
+// ─── draft-21 message parameters (draft-21 §9.20, §16.7) ─────────────
+
+/** draft-21 FILL_PARAMETERS (§9.20.16). */
+const FILL_PARAMETERS = 0x23n;
+
+/** draft-21 Range Filters (§3.3.2): SUBGROUP, OBJECTID, PRIORITY, OBJECT_PROPERTY, TRACK_PROPERTY. */
+const RANGE_FILTER_PARAMS_21 = new Set([0x25n, 0x26n, 0x27n, 0x28n, 0x29n]);
+
+/** Every draft-21 message parameter (draft-21 §16.7). */
+const KNOWN_MESSAGE_PARAMS_21 = new Set([
+ ...KNOWN_MESSAGE_PARAMS_18,
+ FILL_PARAMETERS,
+ ...RANGE_FILTER_PARAMS_21,
+ 0x35n, // INCLUDE_PROPERTIES
+]);
+
+/**
+ * draft-21 message-parameter scope table (§9.20). The same keys as the draft-18
+ * table plus PUBLISH_STATE_NOTIFY; PUBLISH_OK carries only EXPIRES (the
+ * subscription parameters moved to PUBLISH and REQUEST_UPDATE), 0x21 is the
+ * LOCATION_FILTER, which FETCH and PUBLISH_STATE_NOTIFY also carry, and FETCH
+ * gains GROUP_ORDER and SUBSCRIBER_PRIORITY. SUBSCRIBE_TRACKS inherits the
+ * SUBSCRIBE parameter scope under 9.18.1.
+ */
+const VALID_PARAMS_FOR_MESSAGE_TYPE_21: Map> = new Map([
+ [MessageParam.OBJECT_DELIVERY_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'PUBLISH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.AUTHORIZATION_TOKEN as bigint, new Set([
+ 'PUBLISH', 'SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_NAMESPACE', 'SUBSCRIBE_TRACKS',
+ 'PUBLISH_NAMESPACE', 'TRACK_STATUS', 'FETCH',
+ ])],
+ [MessageParam.RENDEZVOUS_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.SUBGROUP_DELIVERY_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'PUBLISH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.EXPIRES as bigint, new Set(['SUBSCRIBE_OK', 'PUBLISH', 'PUBLISH_OK', 'REQUEST_UPDATE_OK',
+ 'SUBSCRIBE_NAMESPACE_OK', 'SUBSCRIBE_TRACKS_OK', 'PUBLISH_NAMESPACE_OK'])],
+ [MessageParam.LARGEST_OBJECT as bigint, new Set([
+ 'SUBSCRIBE_OK', 'PUBLISH', 'REQUEST_UPDATE_OK', 'TRACK_STATUS_OK', 'PUBLISH_STATE_NOTIFY',
+ ])],
+ [MessageParam.FILL_TIMEOUT as bigint, new Set(['FETCH'])],
+ [MessageParam.FORWARD as bigint, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'PUBLISH', 'SUBSCRIBE_TRACKS', 'PUBLISH_STATE_NOTIFY'])],
+ [MessageParam.SUBSCRIBER_PRIORITY as bigint, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'PUBLISH', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.SUBSCRIPTION_FILTER as bigint, new Set([
+ 'SUBSCRIBE', 'REQUEST_UPDATE', 'PUBLISH', 'FETCH', 'SUBSCRIBE_TRACKS', 'PUBLISH_STATE_NOTIFY',
+ ])],
+ [MessageParam.GROUP_ORDER as bigint, new Set(['SUBSCRIBE', 'FETCH', 'PUBLISH', 'SUBSCRIBE_TRACKS'])],
+ [FILL_PARAMETERS, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [0x25n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [0x26n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [0x27n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [0x28n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [0x29n, new Set(['REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.NEW_GROUP_REQUEST as bigint, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])],
+ [MessageParam.TRACK_NAMESPACE_PREFIX as bigint, new Set(['REQUEST_UPDATE'])],
+ [0x35n, new Set(['SUBSCRIBE', 'FETCH', 'TRACK_STATUS', 'SUBSCRIBE_TRACKS'])],
+]);
+
/**
* Whether two Track Namespace Prefixes overlap: one is a (field-wise) prefix of
* the other, so a single PUBLISH_NAMESPACE could match both. Used to reject an
@@ -279,6 +339,12 @@ export interface SetupOptions {
* @see draft-ietf-moq-transport-16 §9.3.1.5
*/
authTokens?: Uint8Array[];
+ /**
+ * draft-21 §9.1.7: the MAX_REQUEST_UPDATES to advertise, the number of
+ * unanswered REQUEST_UPDATEs per request stream we accept. Omitted (or 0)
+ * means no limit; ignored before draft 21.
+ */
+ maxRequestUpdates?: bigint;
}
/**
@@ -305,6 +371,15 @@ export interface SubscribeOptions {
* If omitted, the subscription is unfiltered (all objects pass).
*/
subscriptionFilter?: SubscriptionFilter;
+ /**
+ * draft-21 §3.4 only: FILL_PARAMETERS. The publisher delivers the fill range on
+ * a fill fetch stream whose FETCH_HEADER carries this SUBSCRIBE's Request ID;
+ * the stream ends with a FIN when complete, or is reset when the fill fails.
+ * `filter` is the fill's Location filter (e.g. `RelativeStart` 1 for the
+ * current group); without it the fill inherits the subscription filter. This replaces
+ * the Joining FETCH, which draft 21 removed.
+ */
+ fill?: FillOptions;
}
/**
@@ -333,6 +408,11 @@ export interface RequestUpdateOptions {
* a draft-18-only concept; there is no such update in draft-14/16).
*/
trackNamespacePrefix?: Uint8Array[];
+ /**
+ * draft-21 §3.4: FILL_PARAMETERS. The publisher opens another fill fetch stream
+ * whose FETCH_HEADER carries this REQUEST_UPDATE's Request ID. Draft 21 only.
+ */
+ fill?: FillOptions;
}
/**
@@ -408,6 +488,8 @@ export interface TrackSubscriptionState {
/** Mutable: a §10.9.2 prefix update replaces it on REQUEST_OK. */
trackNamespacePrefix: Uint8Array[];
state: 'pending' | 'active' | 'terminated';
+ /** draft-21 Forward State for future matching subscriptions. */
+ forward: ForwardStateValue;
/** Tracks the publisher reported it cannot serve, via PUBLISH_BLOCKED. */
readonly blockedTracks: Array<{ trackNamespaceSuffix: Uint8Array[]; trackName: Uint8Array }>;
}
@@ -555,6 +637,7 @@ export class Session {
// means the update did not carry that parameter (leave the current value).
objectDeliveryTimeoutMs?: number;
subgroupDeliveryTimeoutMs?: number;
+ subscriptionFilter?: Uint8Array;
}>();
/**
@@ -609,7 +692,7 @@ export class Session {
* OUTBOUND, subscriber side) AND from {@link incomingNamespaceSubscriptions}
* (SUBSCRIBE_NAMESPACE) so prefix-overlap checks never cross request types.
*/
- private readonly incomingTrackSubscriptions = new Map();
+ private readonly incomingTrackSubscriptions = new Map();
/**
* Auth token alias cache for tokens the peer registers with us.
@@ -635,6 +718,16 @@ export class Session {
private _state: SessionStateValue = SessionState.IDLE;
private _newSessionUri: string | undefined;
private _peerMaxRequestId: Varint = varint(0n);
+ private _ownMaxRequestUpdates = 0n;
+ private _peerMaxRequestUpdates = 0n;
+ /** draft-21 fills still expected: fill Request ID → subscription Request ID. */
+ private readonly fillRequests = new Map();
private _goawayReceived: boolean = false;
constructor(
@@ -688,6 +781,77 @@ export class Session {
return this._peerMaxAuthTokenCacheSize;
}
+ /** draft-21 §9.1.7: the MAX_REQUEST_UPDATES we advertised (0 = no limit). */
+ get ownMaxRequestUpdates(): bigint {
+ return this._ownMaxRequestUpdates;
+ }
+
+ /** draft-21 §9.1.7: the peer's MAX_REQUEST_UPDATES (0 = no limit). */
+ get peerMaxRequestUpdates(): bigint {
+ return this._peerMaxRequestUpdates;
+ }
+
+ private registerFillRequest(requestId: bigint, sub: SubscriptionStateMachine, options: FillOptions, filter?: SubscriptionFilter): void {
+ const groupOrder = options.groupOrder ?? sub.requestedGroupOrder;
+ this.fillRequests.set(requestId, {
+ subscriptionId: sub.requestId, subscription: sub, ready: false,
+ filter: options.filter ?? filter,
+ ...(groupOrder === undefined ? {} : { groupOrder }),
+ });
+ sub.fillRequested = true;
+ }
+
+ private settleFillRequest(requestId: bigint, parameters: Parameters): void {
+ const fill = this.fillRequests.get(requestId);
+ if (!fill) return;
+ const filter = fill.filter ?? (fill.subscription.currentFilter === undefined
+ ? undefined : decodeSubscriptionFilter(fill.subscription.currentFilter, 21));
+ if (fill.subscription.forwardState !== ForwardState.ACTIVE
+ || resolveFillWindow(filter, this.extractLargestObjectParam(parameters) ?? undefined) === null) {
+ this.cancelFillRequest(requestId);
+ return;
+ }
+ fill.ready = true;
+ }
+
+ /** The subscription a fill with this Request ID (a SUBSCRIBE's or a REQUEST_UPDATE's) belongs to, while one is expected. */
+ fillSubscriptionFor(requestId: bigint): SubscriptionStateMachine | undefined {
+ // A clean terminal response can overtake its fill data. The I/O owner
+ // releases this context after consuming or explicitly cancelling the fill.
+ return this.fillRequests.get(requestId)?.subscription;
+ }
+
+ /** Effective order for one fill, including its per-message override. */
+ fillGroupOrderFor(requestId: bigint): GroupOrder | undefined {
+ const fill = this.fillRequests.get(requestId);
+ return fill === undefined ? undefined : fill.groupOrder ?? this.fillSubscriptionFor(requestId)?.groupOrder;
+ }
+
+ isFillResponsePending(requestId: bigint): boolean {
+ return this.fillRequests.get(requestId)?.ready === false;
+ }
+
+ /** Accept the fill stream for `requestId`: it stops being expected. */
+ takeFill(requestId: bigint): SubscriptionStateMachine | undefined {
+ const sub = this.fillSubscriptionFor(requestId);
+ this.cancelFillRequest(requestId);
+ return sub;
+ }
+
+ /** Stop expecting the fill for `requestId`; true when one was expected. */
+ cancelFillRequest(requestId: bigint): boolean {
+ const fill = this.fillRequests.get(requestId);
+ if (fill === undefined) return false;
+ this.fillRequests.delete(requestId);
+ fill.subscription.fillRequested = this.expectedFillsOf(fill.subscriptionId).length > 0;
+ return true;
+ }
+
+ /** Request IDs of the fills still expected for subscription `subscriptionId`. */
+ expectedFillsOf(subscriptionId: bigint): bigint[] {
+ return [...this.fillRequests].filter(([, fill]) => fill.subscriptionId === subscriptionId).map(([id]) => id);
+ }
+
// ─── Setup Handshake ──────────────────────────────────────────────────
/**
@@ -697,9 +861,10 @@ export class Session {
initiateSetup(options: SetupOptions = {}): SessionOutboundAction[] {
this.assertState(SessionState.IDLE, 'initiateSetup');
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
// draft-18: send the unified SETUP (no MAX_REQUEST_ID — QUIC stream limits).
const setup = this.setupGate.createSetup18(options);
+ if (isDraft21(this._draftVersion)) this._ownMaxRequestUpdates = options.maxRequestUpdates ?? 0n;
if (options.maxAuthTokenCacheSize !== undefined) {
this._ownMaxAuthTokenCacheSize = Number(options.maxAuthTokenCacheSize);
}
@@ -735,8 +900,9 @@ export class Session {
throw new SessionError('Only server can call completeSetup', 'INVALID_STATE');
}
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
const setup = this.setupGate.createSetup18(options);
+ if (isDraft21(this._draftVersion)) this._ownMaxRequestUpdates = options.maxRequestUpdates ?? 0n;
if (options.maxAuthTokenCacheSize !== undefined) {
this._ownMaxAuthTokenCacheSize = Number(options.maxAuthTokenCacheSize);
}
@@ -816,6 +982,8 @@ export class Session {
return this.handleInboundPublishOk(msg as PublishOk);
case 'PUBLISH_DONE':
return this.handlePublishDone(msg);
+ case 'PUBLISH_STATE_NOTIFY':
+ return this.handlePublishStateNotify(msg);
case 'UNSUBSCRIBE':
return this.handleUnsubscribe(msg);
case 'SUBSCRIBE':
@@ -836,14 +1004,14 @@ export class Session {
// draft-18 only: inbound SUBSCRIBE_NAMESPACE is a publisher-side
// continuing request stream (§10.18). For draft-14/16 it is not handled
// as an inbound message — fall through to the unsupported-type path.
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
return this.handleIncomingSubscribeNamespace(msg as SubscribeNamespace);
}
return this.handleUnsupportedControlMessage(msg);
case 'SUBSCRIBE_TRACKS':
// draft-18 only: inbound SUBSCRIBE_TRACKS is a publisher-side continuing
// request stream (§10.19). It has no draft-14/16 inbound form.
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
return this.handleIncomingSubscribeTracks(msg as SubscribeTracks);
}
return this.handleUnsupportedControlMessage(msg);
@@ -978,6 +1146,7 @@ export class Session {
// draft-18 unified SETUP (role-neutral wire; this side interprets it).
const result = this.setupGate.handleSetup18(msg as Setup);
this._peerMaxRequestId = result.peerMaxRequestId; // 0 — draft-18 has no credit
+ this._peerMaxRequestUpdates = result.peerMaxRequestUpdates ?? 0n;
// No MAX_REQUEST_ID in draft-18: do NOT update the request-id allocator credit.
if (result.peerMaxAuthTokenCacheSize !== undefined) {
this._peerMaxAuthTokenCacheSize = result.peerMaxAuthTokenCacheSize;
@@ -1127,7 +1296,7 @@ export class Session {
// draft-18 Token internals (Alias Type / Token Alias / Token Type) are vi64
// (full uint64); draft-14/16 use QUIC varint. Pick the wire parser by version.
- const parseToken = this._draftVersion === 18 ? parseAuthorizationToken18 : parseAuthorizationToken;
+ const parseToken = isRequestStreamDraft(this._draftVersion) ? parseAuthorizationToken18 : parseAuthorizationToken;
for (const rawValue of values) {
if (!(rawValue instanceof Uint8Array)) continue;
@@ -1209,8 +1378,9 @@ export class Session {
// draft-18 §10.4: a control-stream GOAWAY MUST carry the Request ID, and its
// parity MUST match the receiver's own request-id parity (the GOAWAY refers
- // to the smallest of OUR requests the peer may not have processed).
- if (this._draftVersion === 18) {
+ // to the smallest of OUR requests the peer may not have processed). Draft 21
+ // (§9.2) dropped the Request ID.
+ if (isRequestStreamDraft(this._draftVersion) && !isDraft21(this._draftVersion)) {
if (msg.requestId === undefined) {
return this.closeWithError(
SessionErrorCode.PROTOCOL_VIOLATION,
@@ -1330,6 +1500,15 @@ export class Session {
}
sub.handleSubscribeOk(msg.trackAlias);
+ this.settleFillRequest(msg.requestId as bigint, msg.parameters);
+ if (isDraft21(this._draftVersion)) {
+ const properties = msg.trackProperties ?? msg.trackExtensions;
+ if (properties instanceof Map) sub.publisherGroupOrder = properties.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending';
+ sub.locationWindow = subscriptionWindow(
+ sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21),
+ this.extractLargestObjectParam(msg.parameters) ?? undefined,
+ );
+ }
// Register track alias - §9.10/§10.1: duplicate alias must close connection
const trackInfo = this.subscriptionTracks.get(msg.requestId as bigint);
@@ -1360,7 +1539,8 @@ export class Session {
const pending = this.pendingUpdates.get(msg.requestId as bigint);
if (pending) {
this.pendingUpdates.delete(msg.requestId as bigint);
- // Don't apply the update — it was rejected
+ // Don't apply the update — it was rejected, and it opens no fill.
+ this.fillRequests.delete(msg.requestId as bigint);
return [];
}
@@ -1375,6 +1555,7 @@ export class Session {
const sub = this.subscriptions.get(msg.requestId as bigint);
if (sub) {
sub.handleRequestError(msg.errorCode, msg.errorReason);
+ for (const fillId of this.expectedFillsOf(sub.requestId)) this.cancelFillRequest(fillId);
// §5.1: REQUEST_ERROR is terminal for the (pending) subscribe — RECLAIM the
// full state (bounded). A second REQUEST_ERROR then takes the unknown-request
// path below.
@@ -1593,7 +1774,7 @@ export class Session {
// draft-18 §10.2.1: validate the REQUEST_OK's Message Parameters against the
// scope of the RESPONSE it stands in for, resolved from the request stream
// (PUBLISH_OK / REQUEST_UPDATE_OK / TRACK_STATUS_OK / namespace responses).
- if (this._draftVersion === 18 && (msg.parameters?.size ?? 0) > 0) {
+ if (isRequestStreamDraft(this._draftVersion) && (msg.parameters?.size ?? 0) > 0) {
const context = this.d18RequestOkContext(msg.requestId as bigint);
if (context) {
const paramError = this.validateMessageParams(msg.parameters, context);
@@ -1609,7 +1790,21 @@ export class Session {
// Apply the update to the subscription (outgoing or, for a draft-18
// PUBLISH-initiated subscription, the incoming one).
+ if (pending.subscriptionFilter !== undefined) {
+ const sub = this.subscriptions.get(pending.existingRequestId) ?? this.incomingSubscriptions.get(pending.existingRequestId);
+ if (sub && sub.state !== SubscriptionState.TERMINATED) {
+ sub.currentFilter = pending.subscriptionFilter;
+ sub.locationWindow = subscriptionWindow(
+ decodeSubscriptionFilter(pending.subscriptionFilter, 21),
+ this.extractLargestObjectParam(msg.parameters) ?? undefined,
+ );
+ }
+ }
if (pending.forward !== undefined) {
+ if (pending.prefixTarget === 'tracks') {
+ const tracks = this.trackSubscriptions.get(pending.existingRequestId);
+ if (tracks?.state === 'active') tracks.forward = pending.forward;
+ }
const sub = this.subscriptions.get(pending.existingRequestId)
?? this.incomingSubscriptions.get(pending.existingRequestId);
if (sub) {
@@ -1624,6 +1819,9 @@ export class Session {
// terminated: settle without applying.
}
}
+ // Earlier FIFO responses can change the inherited filter and Forward
+ // state. Decide whether this fill exists only after committing this ack.
+ this.settleFillRequest(msg.requestId as bigint, msg.parameters);
// §8: COMMIT the staged delivery timeouts now that the update is confirmed
// (never at send — see requestUpdate). Omitted parameters leave the current
@@ -1703,6 +1901,9 @@ export class Session {
* delivery-timeout parameters apply only when present.
*/
private applyPublishAcceptanceParams(sub: SubscriptionStateMachine, parameters: Parameters | undefined): void {
+ // Draft 21 PUBLISH_OK carries only EXPIRES; subscription parameters remain
+ // those sent in PUBLISH until an accepted REQUEST_UPDATE changes them.
+ if (isDraft21(this._draftVersion)) return;
const fwd = parameters?.get(MessageParam.FORWARD)?.[0];
// FORWARD omitted from an acceptance defaults to 1. Only omission from a
// REQUEST_UPDATE preserves the existing state (§9.2.2.8 / d18 §10.2.12).
@@ -1736,6 +1937,28 @@ export class Session {
);
}
+ /**
+ * draft-21 §9.10: the publisher reports, on a subscription's request stream,
+ * the Largest Object it has sent, its Forward State and the Location Filter in
+ * effect. The Largest Object advances the subscription's; the rest is the
+ * publisher's view and is surfaced to the application as the message itself.
+ * The topology accepts the message only on a SUBSCRIBE (or inbound PUBLISH)
+ * stream; one that crosses our own cancellation of the request is ignored.
+ */
+ private handlePublishStateNotify(msg: PublishStateNotify): SessionOutboundAction[] {
+ if (!isDraft21(this._draftVersion)) return this.handleUnsupportedControlMessage(msg);
+ const requestId = msg.requestId;
+ const incoming = requestId !== undefined ? this.incomingSubscriptions.get(requestId) : undefined;
+ const sub = requestId !== undefined
+ ? this.subscriptions.get(requestId) ?? (incoming?.isPublishInitiated ? incoming : undefined) : undefined;
+ if (sub === undefined) return [];
+ const largest = msg.parameters.get(MessageParam.LARGEST_OBJECT)?.[0];
+ if (largest !== undefined && typeof largest === 'object' && 'group' in largest) {
+ sub.updateLargestLocation(largest.group, largest.object);
+ }
+ return [];
+ }
+
private handlePublishDone(msg: PublishDone): SessionOutboundAction[] {
// §9.19: "The publisher does not send PUBLISH_DONE for this request"
if (this.pendingTrackStatuses.has(msg.requestId as bigint)) {
@@ -1801,7 +2024,7 @@ export class Session {
* behavior unchanged). An empty namespace is permitted (allowEmptyNamespace).
*/
private validateFullName18(namespace: Uint8Array[], trackName: Uint8Array): SessionOutboundAction[] | null {
- if (this._draftVersion !== 18) return null;
+ if (!isRequestStreamDraft(this._draftVersion)) return null;
try {
validateFullTrackName(namespace, trackName, { allowEmptyNamespace: true });
} catch (e) {
@@ -1816,7 +2039,7 @@ export class Session {
/** Draft-18 §2.4.1 defensive validation for a full Track Namespace (no track name,
* e.g. PUBLISH_NAMESPACE). Same contract as {@link validateFullName18}. */
private validateNamespace18(namespace: Uint8Array[]): SessionOutboundAction[] | null {
- if (this._draftVersion !== 18) return null;
+ if (!isRequestStreamDraft(this._draftVersion)) return null;
try {
validateTrackNamespace(namespace, { allowEmpty: true });
} catch (e) {
@@ -1837,6 +2060,9 @@ export class Session {
const validated = this.validateAndReplenish(msg.requestId);
if (validated.error) return validated.error;
+ const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters);
+ if (filterError) return filterError;
+
// §3.2: reserved `.`/`.session` namespace → REQUEST_ERROR DOES_NOT_EXIST.
const reserved = this.rejectReservedNamespace(msg.trackNamespace, msg.requestId, validated.replenish);
if (reserved) return reserved;
@@ -1849,12 +2075,13 @@ export class Session {
);
// §5.1: at most ONE subscription per Track per ROLE (drafts 16/18 only —
- // draft-14 defines neither this rule nor DUPLICATE_SUBSCRIPTION 0x19). A peer
+ // draft-14 defines neither this rule nor DUPLICATE_SUBSCRIPTION 0x19, and
+ // draft 21 §3.1 allows concurrent subscriptions to one Track). A peer
// SUBSCRIBE makes US the publisher; if we already hold a publisher-role
// subscription for the same Full Track Name — an inbound SUBSCRIBE (incl. one
// Pending) OR a LOCAL outbound PUBLISH — the REQUEST MUST fail with
// DUPLICATE_SUBSCRIPTION. Request-level, not a session close.
- if (this._draftVersion !== 14 && sub.trackKey !== undefined
+ if (this._draftVersion !== 14 && !isDraft21(this._draftVersion) && sub.trackKey !== undefined
&& (this.findLiveIncomingByTrack(sub.trackKey, /* publishInitiated */ false)
|| this.findLiveOutboundPublishByTrack(sub.trackKey))) {
const errorMsg: RequestErrorMsg = {
@@ -1875,6 +2102,7 @@ export class Session {
const filterValues = msg.parameters.get(MessageParam.SUBSCRIPTION_FILTER);
const filterBytes = filterValues?.[0];
if (filterBytes instanceof Uint8Array) {
+ sub.currentFilter = filterBytes;
try {
sub.setRemoteFilterType(decodeSubscriptionFilter(filterBytes, this._draftVersion).type);
} catch { /* malformed — leave unfiltered */ }
@@ -1887,6 +2115,8 @@ export class Session {
if (typeof forwardVal === 'bigint') {
sub.setInitialForwardState(forwardVal === 0n ? ForwardState.PAUSED : ForwardState.ACTIVE);
}
+ const order = msg.parameters.get(MessageParam.GROUP_ORDER)?.[0];
+ if (order !== undefined) sub.requestedGroupOrder = order === 2n ? 'descending' : 'ascending';
this.incomingSubscriptions.set(msg.requestId as bigint, sub);
@@ -1926,12 +2156,16 @@ export class Session {
if (typeof publishForward === 'bigint') {
sub.setInitialForwardState(publishForward === 0n ? ForwardState.PAUSED : ForwardState.ACTIVE);
}
+ if (isDraft21(this._draftVersion)) {
+ this.initializePublishPolicy21(sub, msg.parameters, msg.trackProperties);
+ }
// §5.1: an endpoint may hold at most ONE subscription per Track per ROLE
// (drafts 16/18 only — draft-14 defines neither this rule nor
- // DUPLICATE_SUBSCRIPTION 0x19). A PUBLISH makes US the subscriber. Checked
- // BEFORE touching the alias registry.
- if (this._draftVersion !== 14 && sub.trackKey !== undefined) {
+ // DUPLICATE_SUBSCRIPTION 0x19, and draft 21 §3.1 allows concurrent
+ // subscriptions). A PUBLISH makes US the subscriber. Checked BEFORE
+ // touching the alias registry.
+ if (this._draftVersion !== 14 && !isDraft21(this._draftVersion) && sub.trackKey !== undefined) {
const outboundSub = this.findLiveOutboundSubscribeByTrack(sub.trackKey);
// (a) A committed subscriber-role subscription — an earlier PUBLISH we
// accepted, or a local ESTABLISHED SUBSCRIBE — is a duplicate: reject.
@@ -2009,6 +2243,32 @@ export class Session {
return undefined;
}
+ private assertPublisherAliasAvailable(alias: bigint, namespace: Uint8Array[], name: Uint8Array): void {
+ if (!isDraft21(this._draftVersion)) return;
+ for (const requests of [this.incomingSubscriptions, this.outgoingPublishes]) {
+ for (const sub of requests.values()) {
+ if (!sub.isPublisher || sub.isTerminated || sub.trackAlias !== alias) continue;
+ const sameTrack = sub.trackNamespace?.length === namespace.length
+ && prefixesOverlap(sub.trackNamespace, namespace)
+ && sub.trackName?.length === name.length
+ && sub.trackName.every((byte, index) => byte === name[index]);
+ if (!sameTrack) throw new SessionError(`Track alias ${alias} already belongs to a different track`, 'INVALID_STATE');
+ }
+ }
+ }
+
+ private initializePublishPolicy21(sub: SubscriptionStateMachine, parameters: Parameters, properties: TrackProperties | undefined): void {
+ const filter = parameters.get(MessageParam.SUBSCRIPTION_FILTER)?.[0];
+ if (filter instanceof Uint8Array) sub.currentFilter = filter;
+ const order = parameters.get(MessageParam.GROUP_ORDER)?.[0];
+ if (order !== undefined) sub.requestedGroupOrder = order === 2n ? 'descending' : 'ascending';
+ sub.publisherGroupOrder = properties?.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending';
+ sub.locationWindow = subscriptionWindow(
+ sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21),
+ this.extractLargestObjectParam(parameters) ?? undefined,
+ );
+ }
+
/**
* §5.1: perform a STAGED cancellation of a local SUBSCRIBE superseded by an
* inbound PUBLISH, at the moment we accept that PUBLISH (before PUBLISH_OK). No
@@ -2035,7 +2295,7 @@ export class Session {
requestId: localReqId,
reason: 'local SUBSCRIBE superseded by a peer PUBLISH for the same track (§5.1)',
};
- if (this._draftVersion === 18) return [cancel];
+ if (isRequestStreamDraft(this._draftVersion)) return [cancel];
return [this.sendControl({ type: 'UNSUBSCRIBE', requestId: localReqId } as Unsubscribe), cancel];
}
@@ -2048,6 +2308,7 @@ export class Session {
*/
private terminateLocalSubscriberRequest(sub: SubscriptionStateMachine): void {
const requestId = sub.requestId;
+ for (const fillId of this.expectedFillsOf(requestId)) this.cancelFillRequest(fillId);
// Capture the phase BEFORE superseding: if it was already ESTABLISHED, no crossed
// SUBSCRIBE_OK is legal (§5.1), so the shadow must start ESTABLISHED. (At cancel
// time the SM is PENDING or ESTABLISHED — never yet TERMINATED.)
@@ -2148,6 +2409,9 @@ export class Session {
const validated = this.validateAndReplenish(msg.requestId);
if (validated.error) return validated.error;
+ const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters);
+ if (filterError) return filterError;
+
// Joining Fetch (0x2/0x3): enforce the MUSTs decidable from protocol state
// BEFORE creating any fetch state (§9.16.2 / draft-18 §10.12.2).
if (msg.fetch.fetchType !== 0x1) {
@@ -2190,7 +2454,7 @@ export class Session {
return [this.sendControl(errorMsg), ...(validated.replenish ?? [])];
}
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
// draft-18 §10.12.2: "A Joining Fetch is only permitted when the
// associated subscription has Forward State 1; otherwise the publisher
// MUST respond with a REQUEST_ERROR with error code INVALID_RANGE."
@@ -2336,7 +2600,7 @@ export class Session {
// ONLY when it targets a SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS (handled
// above). On a REQUEST_UPDATE for a normal subscription/fetch/publish it is
// out of scope → PROTOCOL_VIOLATION.
- if (this._draftVersion === 18 && msg.parameters.has(MessageParam.TRACK_NAMESPACE_PREFIX as bigint)) {
+ if (isRequestStreamDraft(this._draftVersion) && msg.parameters.has(MessageParam.TRACK_NAMESPACE_PREFIX as bigint)) {
return this.closeWithError(
SessionErrorCode.PROTOCOL_VIOLATION,
`TRACK_NAMESPACE_PREFIX is out of scope for a REQUEST_UPDATE that does not target a SUBSCRIBE_NAMESPACE/SUBSCRIBE_TRACKS (§10.2.14)`,
@@ -2365,6 +2629,10 @@ export class Session {
// draft-18 §10.9: a PUBLISH_NAMESPACE may be updated on its request stream.
// We have no per-namespace mutable state to apply yet — acknowledge with
// REQUEST_OK (or, for v14, no response). Params are accepted as-is.
+ const contextError = this.validateUpdateContext21(msg.parameters, sub ? 'SUBSCRIBE' : fetch ? 'FETCH' : 'PUBLISH_NAMESPACE');
+ if (contextError) return contextError;
+ const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters);
+ if (filterError) return filterError;
if (pubNs && !sub && !fetch) {
if (this._draftVersion === 14) return validated.replenish ?? [];
const requestOk: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() };
@@ -2377,7 +2645,7 @@ export class Session {
// cover publish-initiated subscriptions: an update acknowledgement before
// PUBLISH_OK has ambiguous correlation on the publish stream.
if (sub && sub.state !== 'established'
- && !(this._draftVersion === 18 && sub.state === 'pending' && !sub.isPublishInitiated)) {
+ && !(isRequestStreamDraft(this._draftVersion) && sub.state === 'pending' && !sub.isPublishInitiated)) {
return this.closeWithError(
SessionErrorCode.PROTOCOL_VIOLATION,
`REQUEST_UPDATE for subscription ${msg.existingRequestId} in state ${sub.state}; expected established`,
@@ -2438,12 +2706,12 @@ export class Session {
// emitted after them carries the current largest and satisfies the save
// requirement for the resumed state. Failing such an update closed here
// would reject the very races §10.12.2 requires publishers to tolerate.
- const wantsResume = this._draftVersion === 18 && isSubscriptionScope
+ const wantsResume = isRequestStreamDraft(this._draftVersion) && isSubscriptionScope
&& sub!.state === 'established'
&& prevForwardState === ForwardState.PAUSED
&& forwardValues !== undefined && forwardValues.length > 0
&& typeof forwardValues[0] === 'bigint' && forwardValues[0] !== 0n;
- if (wantsResume) {
+ if (wantsResume || (isDraft21(this._draftVersion) && isSubscriptionScope)) {
// d18 §10.11: "PUBLISH_DONE ... UPDATE_FAILED": an unsuccessful
// subscription update terminates the subscription. The SESSION emits
// only the REQUEST_ERROR here — the I/O layer owns the termination
@@ -2452,7 +2720,7 @@ export class Session {
// stream, none of which bare Session actions can do). The adapter
// detects the REQUEST_ERROR update response and runs its publishDone.
const failClosed = (why: string): SessionOutboundAction[] => {
- const reason = `cannot resume subscription ${msg.existingRequestId}: ${why} (§5.1 requires the REQUEST_UPDATE_OK to carry the current Largest Location)`;
+ const reason = `cannot update subscription ${msg.existingRequestId}: ${why} (REQUEST_UPDATE_OK requires the current Largest Location)`;
return [this.sendControl({
type: 'REQUEST_ERROR',
requestId: msg.requestId,
@@ -2500,10 +2768,17 @@ export class Session {
// Omitted ⇒ unchanged (per the same section).
const updatedFilter = msg.parameters.get(MessageParam.SUBSCRIPTION_FILTER)?.[0];
if (updatedFilter instanceof Uint8Array && sub) {
+ sub.currentFilter = updatedFilter;
try {
sub.setRemoteFilterType(decodeSubscriptionFilter(updatedFilter, this._draftVersion).type);
} catch { /* malformed bytes were already rejected by parameter validation */ }
}
+ if (isDraft21(this._draftVersion) && isSubscriptionScope && sub && updatedFilter instanceof Uint8Array) {
+ sub.locationWindow = subscriptionWindow(
+ sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21),
+ resumeLocation ?? undefined,
+ );
+ }
// §8: OBJECT/SUBGROUP_DELIVERY_TIMEOUT MAY appear in REQUEST_UPDATE — the receiver
// records the peer's new value on the subscription (omitted ⇒ unchanged, §9.11).
@@ -2566,11 +2841,19 @@ export class Session {
);
}
+ const contextError = this.validateUpdateContext21(msg.parameters, incNs ? 'SUBSCRIBE_NAMESPACE' : 'SUBSCRIBE_TRACKS');
+ if (contextError) return contextError;
+ const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters);
+ if (filterError) return filterError;
+
// §10.9: a parameter absent from REQUEST_UPDATE leaves its value unchanged.
- // No TRACK_NAMESPACE_PREFIX → nothing to change; acknowledge.
+ // Forward on SUBSCRIBE_TRACKS changes future subscriptions only (§9.20.19).
+ const forward = isDraft21(this._draftVersion) && incTracks
+ ? msg.parameters.get(MessageParam.FORWARD)?.[0] : undefined;
const prefixVals = msg.parameters.get(MessageParam.TRACK_NAMESPACE_PREFIX as bigint);
const newPrefix = prefixVals && prefixVals.length > 0 ? prefixVals[prefixVals.length - 1] : undefined;
if (newPrefix === undefined) {
+ if (incTracks && typeof forward === 'bigint') incTracks.forward = Number(forward) as ForwardStateValue;
const ok: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() };
return [this.sendControl(ok), ...(replenish ?? [])];
}
@@ -2607,6 +2890,7 @@ export class Session {
}
}
incTracks.trackNamespacePrefix = newPrefix;
+ if (typeof forward === 'bigint') incTracks.forward = Number(forward) as ForwardStateValue;
}
const ok: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() };
@@ -2695,6 +2979,14 @@ export class Session {
}
const requestedForward = this.normalizeLocalForwardState(options.forward, 'SUBSCRIBE');
+ if (options.fill !== undefined && !isDraft21(this._draftVersion)) {
+ throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`);
+ }
+ // Encode before allocating: bad local options must not leave a pending request.
+ const filterBytes = options.subscriptionFilter === undefined
+ ? undefined : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion);
+ const fillBytes = options.fill === undefined
+ ? undefined : encodeFillParameters(options.fill.filter, options.fill.groupOrder);
const requestId = this.requestIdAllocator.allocate();
@@ -2718,6 +3010,7 @@ export class Session {
}
if (options.groupOrder !== undefined) {
parameters.set(MessageParam.GROUP_ORDER, [options.groupOrder]);
+ sub.requestedGroupOrder = options.groupOrder === 2n ? 'descending' : 'ascending';
}
if (requestedForward !== undefined) {
// §9.2.2.8: FORWARD MAY appear in SUBSCRIBE — initial forward state.
@@ -2725,12 +3018,17 @@ export class Session {
parameters.set(MessageParam.FORWARD, [varint(BigInt(requestedForward))]);
sub.setInitialForwardState(requestedForward);
}
- if (options.subscriptionFilter !== undefined) {
- const filterBytes = encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion);
+ if (filterBytes !== undefined) {
parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]);
// Store for draft-14 SUBSCRIBE_UPDATE replay
sub.currentFilter = filterBytes;
}
+ if (fillBytes !== undefined) {
+ parameters.set(FILL_PARAMETERS, [fillBytes]);
+ if (sub.forwardState === ForwardState.ACTIVE) {
+ this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter);
+ }
+ }
const subscribeMsg: Subscribe = {
type: 'SUBSCRIBE',
@@ -2769,7 +3067,12 @@ export class Session {
namespace: Uint8Array[],
name: Uint8Array,
trackAlias: bigint,
- options: { parameters?: Parameters; trackProperties?: TrackProperties } = {},
+ options: {
+ parameters?: Parameters;
+ trackProperties?: TrackProperties;
+ /** draft-21: the accepted SUBSCRIBE_TRACKS that caused this PUBLISH. */
+ subscribeTracksRequestId?: bigint;
+ } = {},
): RequestResult {
this.assertEstablishedOrDraining('publish');
this.assertNotReservedNamespace(namespace, 'publish');
@@ -2787,6 +3090,24 @@ export class Session {
// PUBLISH and leave a locally-authorized subscription behind if encoding
// later rejected it.
this.assertLocalForwardParameter(options.parameters, 'PUBLISH');
+ if (options.subscribeTracksRequestId !== undefined) {
+ const tracks = this.incomingTrackSubscriptions.get(options.subscribeTracksRequestId);
+ if (!isDraft21(this._draftVersion) || tracks?.state !== 'active'
+ || tracks.trackNamespacePrefix.length > namespace.length
+ || !prefixesOverlap(tracks.trackNamespacePrefix, namespace)) {
+ throw new SessionError('PUBLISH requires an active matching draft-21 SUBSCRIBE_TRACKS', 'INVALID_STATE');
+ }
+ // Inherit only when the caller identifies this as a resulting PUBLISH.
+ // Independent publications retain publisher authority (§9.8 / §9.18.1).
+ const parameters = new Map(options.parameters);
+ parameters.set(MessageParam.FORWARD, [varint(BigInt(tracks.forward))]);
+ options = { ...options, parameters };
+ }
+ this.assertPublisherAliasAvailable(trackAlias, namespace, name);
+ if (isDraft21(this._draftVersion)) {
+ const error = this.validateMessageParams(options.parameters ?? new Map(), 'PUBLISH');
+ if (error) throw new SessionError(error.reason, 'INVALID_STATE');
+ }
const trackProperties = this.resolveTrackProperties(options.trackProperties, 'PUBLISH');
@@ -2799,6 +3120,7 @@ export class Session {
// Location, exactly as with SUBSCRIBE_OK.
const communicated = this.extractLargestObjectParam(options.parameters);
if (communicated) this.incomingJoiningLocations.set(requestId as bigint, communicated);
+ if (isDraft21(this._draftVersion)) this.initializePublishPolicy21(sub, options.parameters ?? new Map(), trackProperties);
// The PUBLISH's FORWARD parameter sets the subscription's initial Forward
// State (§10.10) — the d18 joining-fetch gate consults it at establish.
const initialForward = options.parameters?.get(MessageParam.FORWARD)?.[0];
@@ -2866,7 +3188,7 @@ export class Session {
// draft-18 removed the UNSUBSCRIBE message; the subscriber cancels by tearing
// down the request stream (RESET_STREAM + STOP_SENDING, §3.3.2) — the I/O layer
// does that; emit NO control message. draft-14/16 send UNSUBSCRIBE.
- if (this._draftVersion === 18) return [];
+ if (isRequestStreamDraft(this._draftVersion)) return [];
return [this.sendControl({ type: 'UNSUBSCRIBE', requestId } as Unsubscribe)];
}
@@ -2883,6 +3205,24 @@ export class Session {
*/
rollbackRequestUpdate(requestId: bigint): void {
this.pendingUpdates.delete(requestId as bigint);
+ this.fillRequests.delete(requestId as bigint);
+ }
+
+ private validateUpdateContext21(params: Parameters, requestType: string): SessionOutboundAction[] | undefined {
+ if (!isDraft21(this._draftVersion)) return undefined;
+ for (const key of params.keys()) {
+ const scopes = VALID_PARAMS_FOR_MESSAGE_TYPE_21.get(key);
+ if (!scopes) continue;
+ const prefixUpdate = key === MessageParam.TRACK_NAMESPACE_PREFIX
+ && (requestType === 'SUBSCRIBE_NAMESPACE' || requestType === 'SUBSCRIBE_TRACKS');
+ const subscriptionOnly = key === FILL_PARAMETERS || key === MessageParam.SUBSCRIPTION_FILTER
+ || (key >= 0x25n && key <= 0x28n);
+ if (!prefixUpdate && (!scopes.has(requestType) || (requestType === 'FETCH' && subscriptionOnly))) {
+ return this.closeWithError(SessionErrorCode.PROTOCOL_VIOLATION,
+ `Parameter 0x${key.toString(16)} is out of scope for REQUEST_UPDATE on ${requestType} (9.20)`);
+ }
+ }
+ return undefined;
}
/**
@@ -2896,6 +3236,23 @@ export class Session {
): RequestResult {
this.assertEstablishedOrDraining('requestUpdate');
const requestedForward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE');
+ // draft-21 §9.1.7: never more than the peer's MAX_REQUEST_UPDATES unanswered
+ // updates on one request stream; each REQUEST_OK / REQUEST_ERROR restores one.
+ if (isDraft21(this._draftVersion) && this._peerMaxRequestUpdates > 0n) {
+ let outstanding = 0n;
+ for (const pending of this.pendingUpdates.values()) {
+ if (pending.existingRequestId === (existingRequestId as bigint)) outstanding += 1n;
+ }
+ if (outstanding >= this._peerMaxRequestUpdates) {
+ throw new SessionError(
+ `${outstanding} REQUEST_UPDATEs on request ${existingRequestId} are unanswered; the peer's MAX_REQUEST_UPDATES is ${this._peerMaxRequestUpdates}`,
+ 'INVALID_STATE',
+ );
+ }
+ }
+ if (options.fill !== undefined && !isDraft21(this._draftVersion)) {
+ throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`);
+ }
// draft-18 §10.9.2: a prefix update targets an outbound SUBSCRIBE_NAMESPACE /
// SUBSCRIBE_TRACKS request (tracked in separate maps, not `subscriptions`).
@@ -2925,7 +3282,7 @@ export class Session {
// draft-18 §10.12.2 contemplates REQUEST_UPDATE racing establishment
// ("process any pending REQUEST_UPDATE messages ... before evaluating"),
// so a d18 subscriber may update a still-PENDING subscription.
- const updatableWhilePending = this._draftVersion === 18 && sub.state === 'pending';
+ const updatableWhilePending = isRequestStreamDraft(this._draftVersion) && sub.state === 'pending';
if (sub.state !== 'established' && !updatableWhilePending) {
throw new SessionError(
`Cannot update subscription in state ${sub.state}; expected established`,
@@ -2933,6 +3290,13 @@ export class Session {
);
}
+ const filterBytes = options.subscriptionFilter === undefined ? undefined
+ : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion);
+ const fillBytes = options.fill === undefined ? undefined
+ : encodeFillParameters(options.fill.filter, options.fill.groupOrder);
+ if (options.subgroupDeliveryTimeout !== undefined && !isRequestStreamDraft(this._draftVersion)) {
+ throw new SessionError('subgroupDeliveryTimeout (SUBGROUP_DELIVERY_TIMEOUT, 0x06) is draft-18 only', 'INVALID_STATE');
+ }
const requestId = this.requestIdAllocator.allocate();
// Build parameters
@@ -2959,26 +3323,16 @@ export class Session {
}
// §9.2.2.5: SUBSCRIPTION_FILTER MAY appear in REQUEST_UPDATE
- if (options.subscriptionFilter !== undefined) {
- const filterBytes = encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion);
+ if (filterBytes !== undefined) {
parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]);
// Update stored filter for future draft-14 replays
- sub.currentFilter = filterBytes;
+ if (!isDraft21(this._draftVersion)) sub.currentFilter = filterBytes;
} else if (this._draftVersion === 14 && sub.currentFilter) {
// Draft-14 §9.10: Start Location and End Group are mandatory inline fields.
// If no new filter specified, replay the current filter to avoid widening.
parameters.set(MessageParam.SUBSCRIPTION_FILTER, [sub.currentFilter]);
}
- // §8 / §10.2.3: SUBGROUP_DELIVERY_TIMEOUT (0x06) is a draft-18-only Message
- // Parameter — emitting it on draft-14/16 would make a conformant peer close on
- // the unknown parameter, so reject it locally outside draft-18.
- if (options.subgroupDeliveryTimeout !== undefined && this._draftVersion !== 18) {
- throw new SessionError(
- 'subgroupDeliveryTimeout (SUBGROUP_DELIVERY_TIMEOUT, 0x06) is draft-18 only',
- 'INVALID_STATE',
- );
- }
// §8: OBJECT/SUBGROUP_DELIVERY_TIMEOUT MAY appear in REQUEST_UPDATE. STAGE the new
// values on the pending record (drafts 16/18 apply on REQUEST_OK; draft-14 inline
// below) so a rejected update / write rollback cannot commit them prematurely. An
@@ -2989,6 +3343,10 @@ export class Session {
if (options.subgroupDeliveryTimeout !== undefined) {
parameters.set(MessageParam.SUBGROUP_DELIVERY_TIMEOUT, [options.subgroupDeliveryTimeout]);
}
+ if (fillBytes !== undefined) {
+ parameters.set(FILL_PARAMETERS, [fillBytes]);
+ this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter);
+ }
if (this._draftVersion === 14) {
// Draft-14 §9.10: "There is no control message in response to a
@@ -3005,12 +3363,16 @@ export class Session {
const pending: {
existingRequestId: bigint; forward?: ForwardStateValue;
objectDeliveryTimeoutMs?: number; subgroupDeliveryTimeoutMs?: number;
+ subscriptionFilter?: Uint8Array;
} = { existingRequestId: existingRequestId as bigint };
if (requestedForward !== undefined) {
pending.forward = requestedForward;
}
if (options.objectDeliveryTimeout !== undefined) pending.objectDeliveryTimeoutMs = Number(options.objectDeliveryTimeout);
if (options.subgroupDeliveryTimeout !== undefined) pending.subgroupDeliveryTimeoutMs = Number(options.subgroupDeliveryTimeout);
+ if (isDraft21(this._draftVersion) && options.subscriptionFilter !== undefined) {
+ pending.subscriptionFilter = parameters.get(MessageParam.SUBSCRIPTION_FILTER)![0] as Uint8Array;
+ }
this.pendingUpdates.set(requestId as bigint, pending);
}
@@ -3037,7 +3399,7 @@ export class Session {
existingRequestId: bigint,
options: RequestUpdateOptions,
): RequestResult {
- if (this._draftVersion !== 18) {
+ if (!isRequestStreamDraft(this._draftVersion)) {
throw new SessionError(
`Track Namespace Prefix REQUEST_UPDATE requires draft-18 (current draft-${this._draftVersion})`,
'INVALID_STATE',
@@ -3055,24 +3417,32 @@ export class Session {
}
const prefix = options.trackNamespacePrefix;
- if (!prefix) {
+ const forward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE');
+ const supportsForward = isDraft21(this._draftVersion) && trackSub !== undefined;
+ if (!prefix && !(supportsForward && forward !== undefined)) {
throw new SessionError(
`REQUEST_UPDATE for a ${target === 'namespace' ? 'SUBSCRIBE_NAMESPACE' : 'SUBSCRIBE_TRACKS'} requires trackNamespacePrefix`,
'INVALID_STATE',
);
}
+ if (forward !== undefined && !supportsForward) {
+ throw new SessionError('FORWARD prefix updates require draft-21 SUBSCRIBE_TRACKS', 'INVALID_STATE');
+ }
try {
- validateTrackNamespacePrefix(prefix);
+ if (prefix) validateTrackNamespacePrefix(prefix);
} catch (e) {
throw new SessionError(e instanceof Error ? e.message : 'Malformed Track Namespace Prefix', 'PROTOCOL_VIOLATION');
}
const requestId = this.requestIdAllocator.allocate();
- const parameters: Parameters = new Map([[MessageParam.TRACK_NAMESPACE_PREFIX as bigint, [prefix]]]);
+ const parameters: Parameters = new Map();
+ if (prefix) parameters.set(MessageParam.TRACK_NAMESPACE_PREFIX as bigint, [prefix]);
+ if (forward !== undefined) parameters.set(MessageParam.FORWARD, [varint(BigInt(forward))]);
this.pendingUpdates.set(requestId as bigint, {
existingRequestId: existingRequestId as bigint,
- namespacePrefix: prefix,
+ ...(prefix === undefined ? {} : { namespacePrefix: prefix }),
prefixTarget: target,
+ ...(forward === undefined ? {} : { forward }),
});
const updateMsg: RequestUpdate = { type: 'REQUEST_UPDATE', requestId, existingRequestId, parameters };
return { requestId, actions: [this.sendControl(updateMsg)] };
@@ -3346,7 +3716,7 @@ export class Session {
this.fetches.delete(requestId as bigint);
if (!established) this.recordCancellation(requestId, 'fetch', false);
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
// draft-18 removed the FETCH_CANCEL control message (§3.3.2): cancellation
// is STOP_SENDING / RESET_STREAM on the request + data streams, performed by
// the I/O layer. No control message is emitted.
@@ -3397,10 +3767,20 @@ export class Session {
/**
* Get track info by alias.
*/
- getTrackByAlias(alias: Varint) {
+ getTrackByAlias(alias: bigint) {
return this.trackAliases.getByAlias(alias);
}
+ /** Live subscriber-side requests sharing one peer-assigned Track Alias. */
+ getSubscriberRequestsForAlias(alias: bigint): SubscriptionStateMachine[] {
+ const result: SubscriptionStateMachine[] = [];
+ for (const id of this.trackAliases.owners(alias)) {
+ const sub = this.subscriptions.get(id) ?? this.incomingSubscriptions.get(id);
+ if (sub && !sub.isTerminated) result.push(sub);
+ }
+ return result;
+ }
+
// ─── Namespace Discovery Operations ──────────────────────────────────
/**
@@ -3474,7 +3854,7 @@ export class Session {
this._newSessionUri ?? '',
);
}
- if (this._draftVersion !== 18) {
+ if (!isRequestStreamDraft(this._draftVersion)) {
throw new SessionError('SUBSCRIBE_TRACKS is a draft-18 message', 'INVALID_STATE');
}
@@ -3483,6 +3863,7 @@ export class Session {
trackNamespacePrefix: namespacePrefix,
state: 'pending',
blockedTracks: [],
+ forward: ForwardState.ACTIVE,
});
const msg: SubscribeTracks = {
@@ -3616,7 +3997,7 @@ export class Session {
// draft-18 §3.3.2: no PUBLISH_NAMESPACE_DONE on the wire — withdrawal is a
// request-stream cancellation handled by the I/O layer. Terminate state only.
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
return [];
}
@@ -3684,7 +4065,7 @@ export class Session {
*/
private resolveTrackProperties(trackProperties: TrackProperties | undefined, context: string): TrackProperties {
const props = trackProperties ?? new Map();
- if (this._draftVersion !== 18 && props.size > 0) {
+ if (!isRequestStreamDraft(this._draftVersion) && props.size > 0) {
throw new SessionError(
`Track Properties on ${context} require draft-18 (current draft-${this._draftVersion})`,
'INVALID_STATE',
@@ -3947,7 +4328,7 @@ export class Session {
message: msg,
};
// draft-18: track the inbound announce so a stream FIN/reset can withdraw it.
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
this.incomingPublishNamespaces.set(msg.requestId as bigint, { namespace: msg.trackNamespace });
}
return [this.sendControl(okMsg), notifyAction, ...(validated.replenish ?? [])];
@@ -3955,7 +4336,7 @@ export class Session {
// Match found — record the announced namespace.
match.nsSm.handleNamespace(msg.trackNamespace);
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
this.incomingPublishNamespaces.set(msg.requestId as bigint, { namespace: msg.trackNamespace });
}
@@ -4150,6 +4531,9 @@ export class Session {
const validated = this.validateAndReplenish(msg.requestId);
if (validated.error) return validated.error;
+ const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters);
+ if (filterError) return filterError;
+
try {
validateTrackNamespacePrefix(msg.trackNamespacePrefix);
} catch (e) {
@@ -4181,6 +4565,8 @@ export class Session {
this.incomingTrackSubscriptions.set(msg.requestId as bigint, {
trackNamespacePrefix: msg.trackNamespacePrefix,
state: 'pending',
+ forward: isDraft21(this._draftVersion) && msg.parameters.get(MessageParam.FORWARD)?.[0] === 0n
+ ? ForwardState.PAUSED : ForwardState.ACTIVE,
});
return validated.replenish ?? [];
}
@@ -4237,7 +4623,7 @@ export class Session {
}
/** Whether we are serving an incoming SUBSCRIBE_TRACKS (pending or active). */
- getIncomingTrackSubscription(requestId: bigint): { trackNamespacePrefix: Uint8Array[]; state: 'pending' | 'active' } | undefined {
+ getIncomingTrackSubscription(requestId: bigint): { trackNamespacePrefix: Uint8Array[]; state: 'pending' | 'active'; forward: ForwardStateValue } | undefined {
return this.incomingTrackSubscriptions.get(requestId as bigint);
}
@@ -4606,6 +4992,10 @@ export class Session {
// Reject local misuse before sendSubscribeOk() establishes the state
// machine or installs any alias authority.
this.assertLocalForwardParameter(options.parameters, 'PUBLISH acceptance');
+ if (isDraft21(this._draftVersion)) {
+ const error = this.validateMessageParams(options.parameters ?? new Map(), 'PUBLISH_OK');
+ if (error) throw new SessionError(error.reason, 'INVALID_STATE');
+ }
}
// Validate ALL misuse up-front, BEFORE any state mutation: draft-14/16 Track
@@ -4621,6 +5011,12 @@ export class Session {
);
}
+ if (!sub.isPublishInitiated && isDraft21(this._draftVersion)) {
+ this.assertPublisherAliasAvailable(trackAlias, sub.trackNamespace!, sub.trackName!);
+ const error = this.validateMessageParams(options.parameters ?? new Map(), 'SUBSCRIBE_OK');
+ if (error) throw new SessionError(error.reason, 'INVALID_STATE');
+ }
+
sub.sendSubscribeOk(trackAlias);
// PUBLISH-initiated subscriptions respond with a publish-acceptance message
@@ -4634,7 +5030,7 @@ export class Session {
// capacity), return ONLY the close — do NOT also append an acceptance.
if (preActions.some((a) => a.type === 'close_connection')) return preActions;
let responseParameters: Parameters;
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
responseParameters = options.parameters ?? new Map();
} else {
// Draft-14/16 §9.14: derived initial-state fields first, with caller-
@@ -4648,13 +5044,15 @@ export class Session {
// Apply the exact value communicated on the wire, including the default
// of 1 when FORWARD is absent, so the two endpoints cannot disagree.
const acceptFwd = responseParameters.get(MessageParam.FORWARD)?.[0];
- sub.updateForwardState(
- typeof acceptFwd === 'bigint' && acceptFwd === 0n
- ? ForwardState.PAUSED
- : ForwardState.ACTIVE,
- );
+ if (!isDraft21(this._draftVersion)) {
+ sub.updateForwardState(
+ typeof acceptFwd === 'bigint' && acceptFwd === 0n
+ ? ForwardState.PAUSED
+ : ForwardState.ACTIVE,
+ );
+ }
- if (this._draftVersion === 18) {
+ if (isRequestStreamDraft(this._draftVersion)) {
// draft-18 §10.10: PUBLISH_OK is REQUEST_OK shorthand (wire 0x07, no
// Request ID); the I/O layer writes it on the inbound PUBLISH request
// stream, not the control stream. Caller-supplied response parameters
@@ -4680,6 +5078,13 @@ export class Session {
const communicated = this.extractLargestObjectParam(options.parameters);
if (communicated) this.incomingJoiningLocations.set(requestId as bigint, communicated);
+ if (isDraft21(this._draftVersion)) {
+ sub.publisherGroupOrder = trackProperties.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending';
+ sub.locationWindow = subscriptionWindow(
+ sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21), communicated ?? undefined,
+ );
+ }
+
const subscribeOk: SubscribeOk = {
type: 'SUBSCRIBE_OK',
requestId,
@@ -4782,6 +5187,7 @@ export class Session {
existingRequestId: bigint,
options: RequestUpdateOptions = {},
): RequestResult {
+ this.assertEstablishedOrDraining('updateIncomingSubscription');
const sub = this.incomingSubscriptions.get(existingRequestId as bigint);
if (!sub) {
throw new SessionError(
@@ -4797,9 +5203,22 @@ export class Session {
}
const requestedForward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE');
-
+ if (options.fill !== undefined && !isDraft21(this._draftVersion)) {
+ throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`);
+ }
+ if (isDraft21(this._draftVersion) && this._peerMaxRequestUpdates > 0n) {
+ const pending = [...this.pendingUpdates.values()].filter((p) => p.existingRequestId === existingRequestId).length;
+ if (BigInt(pending) >= this._peerMaxRequestUpdates) throw new SessionError('MAX_REQUEST_UPDATES exceeded', 'INVALID_STATE');
+ }
+ const filterBytes = options.subscriptionFilter === undefined ? undefined : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion);
+ const fillBytes = options.fill === undefined ? undefined : encodeFillParameters(options.fill.filter, options.fill.groupOrder);
const requestId = this.requestIdAllocator.allocate();
const parameters: Parameters = new Map();
+ if (filterBytes !== undefined) parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]);
+ if (fillBytes !== undefined) {
+ parameters.set(FILL_PARAMETERS, [fillBytes]);
+ this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter);
+ }
if (requestedForward !== undefined) {
parameters.set(MessageParam.FORWARD, [varint(BigInt(requestedForward))]);
}
@@ -4817,6 +5236,7 @@ export class Session {
// Stage ALL changes on the pending record — applied on REQUEST_OK, never at send.
this.pendingUpdates.set(requestId as bigint, {
existingRequestId,
+ ...(isDraft21(this._draftVersion) && filterBytes !== undefined ? { subscriptionFilter: filterBytes } : {}),
...(requestedForward !== undefined
? { forward: requestedForward }
: {}),
@@ -4956,6 +5376,7 @@ export class Session {
*/
close(error?: Varint, reason?: string): SessionOutboundAction[] {
this._state = SessionState.CLOSED;
+ this.fillRequests.clear();
const closeAction: CloseConnectionAction = {
type: 'close_connection',
@@ -4977,6 +5398,7 @@ export class Session {
*/
handleTransportClosed(): void {
this._state = SessionState.CLOSED;
+ this.fillRequests.clear();
}
/**
@@ -4985,6 +5407,7 @@ export class Session {
*/
private closeWithError(error: Varint, reason: string): SessionOutboundAction[] {
this._state = SessionState.CLOSED;
+ this.fillRequests.clear();
return [{
type: 'close_connection',
@@ -5053,7 +5476,7 @@ export class Session {
requestId: bigint,
replenish: SessionOutboundAction[] | undefined,
): SessionOutboundAction[] | undefined {
- if (this._draftVersion !== 18) return undefined;
+ if (!isRequestStreamDraft(this._draftVersion)) return undefined;
const isDot = isReservedDotNamespace(namespace);
if (!isDot && !isReservedSessionNamespace(namespace)) return undefined;
const errorMsg: RequestErrorMsg = {
@@ -5083,7 +5506,7 @@ export class Session {
const combined = [...prefix, ...suffix];
try {
// §2.4.1: draft-18 permits a zero-field full namespace; draft-14/16 do not.
- validateTrackNamespace(combined, { allowEmpty: this._draftVersion === 18 });
+ validateTrackNamespace(combined, { allowEmpty: isRequestStreamDraft(this._draftVersion) });
} catch (e) {
return this.closeWithError(
SessionErrorCode.PROTOCOL_VIOLATION,
@@ -5114,22 +5537,31 @@ export class Session {
return true;
}
- const table = this._draftVersion === 18
- ? VALID_PARAMS_FOR_MESSAGE_TYPE_18
- : VALID_PARAMS_FOR_MESSAGE_TYPE;
+ const table = isDraft21(this._draftVersion)
+ ? VALID_PARAMS_FOR_MESSAGE_TYPE_21
+ : isRequestStreamDraft(this._draftVersion)
+ ? VALID_PARAMS_FOR_MESSAGE_TYPE_18
+ : VALID_PARAMS_FOR_MESSAGE_TYPE;
return table.get(key as bigint)?.has(messageType) ?? false;
}
- /**
- * Validate message parameters: check for unknown types, invalid duplicates, and value constraints.
- * AUTHORIZATION_TOKEN may repeat (§9.2.2.1), other known types must be unique.
- * Parameters not valid for the given message type are ignored per §9.2.2.
- * @see draft-ietf-moq-transport-16 §9.2
- * @returns Error with code and reason if validation fails, undefined if valid
- */
+ /** We advertise the default MAX_FILTER_RANGES=0; never silently ignore a filter. */
+ private rejectUnsupportedRangeFilters(requestId: bigint, params: Parameters): SessionOutboundAction[] | undefined {
+ if (!isDraft21(this._draftVersion)) return undefined;
+ const nested = params.get(FILL_PARAMETERS)?.[0];
+ const fill = nested instanceof Uint8Array ? decodeFillParameters(nested).parameters : undefined;
+ if (![...RANGE_FILTER_PARAMS_21].some((type) => params.has(type) || fill?.has(type))) return undefined;
+ return [this.sendControl({ type: 'REQUEST_ERROR', requestId, errorCode: varint(0x36n),
+ retryInterval: varint(0n), errorReason: 'Range filters exceed MAX_FILTER_RANGES=0 (9.1.6)',
+ })];
+ }
+
+ /** Validate parameter scope, multiplicity, and values using the selected draft. */
private validateMessageParams(params: Parameters, messageType: string): { error: Varint; reason: string } | undefined {
- const isDraft18 = this._draftVersion === 18;
- const knownParams = isDraft18 ? KNOWN_MESSAGE_PARAMS_18 : KNOWN_MESSAGE_PARAMS;
+ const isDraft18 = isRequestStreamDraft(this._draftVersion);
+ const knownParams = isDraft21(this._draftVersion)
+ ? KNOWN_MESSAGE_PARAMS_21
+ : isDraft18 ? KNOWN_MESSAGE_PARAMS_18 : KNOWN_MESSAGE_PARAMS;
for (const [key, values] of params) {
// §9.2: Unknown message parameters are a protocol violation (draft-16/18).
// Draft-14: ignore unknown params — different param sets between versions.
@@ -5152,7 +5584,9 @@ export class Session {
// §9.2.2.1: AUTHORIZATION_TOKEN may appear multiple times
// All other known message parameters must be unique
- if (key !== MessageParam.AUTHORIZATION_TOKEN && values.length > 1) {
+ // draft-21 §3.3.2: Range Filters may repeat (one per SetID / Property Type).
+ if (key !== MessageParam.AUTHORIZATION_TOKEN && values.length > 1
+ && !(isDraft21(this._draftVersion) && RANGE_FILTER_PARAMS_21.has(key as bigint))) {
return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `Duplicate message parameter type: ${key}` };
}
@@ -5210,6 +5644,11 @@ export class Session {
// Even-type parameters: varint value constraint checks → PROTOCOL_VIOLATION
if (typeof value === 'bigint') {
switch (key) {
+ case 0x35n:
+ if (isDraft21(this._draftVersion) && value !== 0n && value !== 1n) {
+ return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `INCLUDE_PROPERTIES must be 0 or 1, got ${value}` };
+ }
+ break;
// DELIVERY_TIMEOUT (0x02). draft-16 §9.2.2.2: MUST be > 0. draft-14 allows
// 0; draft-18 §10.2.4 renames it OBJECT_DELIVERY_TIMEOUT where 0 means
// "no timeout" (and SUBGROUP_DELIVERY_TIMEOUT 0x06 is unconstrained) — so
@@ -5259,6 +5698,14 @@ export class Session {
// a typed Location for LARGEST_OBJECT, which is valid by construction.
if (value instanceof Uint8Array) {
switch (key) {
+ case FILL_PARAMETERS:
+ if (isDraft21(this._draftVersion)) {
+ try { decodeFillParameters(value); }
+ catch (err) {
+ return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `Invalid FILL_PARAMETERS: ${String(err)}` };
+ }
+ }
+ break;
// §9.2.2.7: LARGEST_OBJECT is a Location structure (two varints).
case MessageParam.LARGEST_OBJECT:
return this.validateLargestObject(value);
@@ -5336,7 +5783,7 @@ export class Session {
// scope depends on WHICH request it answers, which is only known once the
// request stream is resolved — so defer to handleRequestOk. (draft-14/16
// REQUEST_OK keeps the by-type scope here.)
- if (this._draftVersion === 18 && msg.type === 'REQUEST_OK') {
+ if (isRequestStreamDraft(this._draftVersion) && msg.type === 'REQUEST_OK') {
return undefined;
}
return this.validateMessageParams(msg.parameters, msg.type);
diff --git a/packages/transport/src/session/setup.ts b/packages/transport/src/session/setup.ts
index 569cfba5..7dc7d4fd 100644
--- a/packages/transport/src/session/setup.ts
+++ b/packages/transport/src/session/setup.ts
@@ -12,10 +12,11 @@
import { varint, type Varint } from '../primitives/varint.js';
import type { ControlMessage, ClientSetup, ServerSetup, Parameters, Setup, SetupOptionMap } from '../control/messages.js';
import { SetupParam } from '../control/parameters.js';
-import { SetupOption18 } from '../control/codes-18.js';
+import { SetupOption18, SetupOption21 } from '../control/codes-18.js';
import type { DraftVersion } from '../control/codec.js';
import { EndpointRole, type EndpointRoleValue, SessionState, type SessionStateValue } from './types.js';
import { AliasType, parseAuthorizationToken, parseAuthorizationToken18, type AuthorizationToken } from '../control/auth-token.js';
+import { isDraft21, isRequestStreamDraft } from '../versions.js';
/**
* Error thrown for setup handshake violations.
@@ -57,6 +58,8 @@ export interface SetupResult {
* @see draft-ietf-moq-transport-18 §10.3.1.3
*/
readonly peerMaxAuthTokenCacheSize?: bigint;
+ /** draft-21 §9.1.7 MAX_REQUEST_UPDATES from the peer's SETUP (absent = 0, no limit). */
+ readonly peerMaxRequestUpdates?: bigint;
/**
* Parsed AUTHORIZATION_TOKEN parameters from the peer's setup message.
* These are the raw parsed tokens — caller must process through AuthTokenCache.
@@ -148,7 +151,7 @@ export class SetupGate {
* @throws {SetupError} If message violates handshake rules
*/
validateMessage(msg: ControlMessage): void {
- if (this.draftVersion === 18) {
+ if (isRequestStreamDraft(this.draftVersion)) {
// draft-18: each side sends a single unified SETUP on its uni control
// stream; the first message received before ESTABLISHED must be SETUP.
if (this.state !== SessionState.ESTABLISHED && msg.type !== 'SETUP') {
@@ -203,6 +206,8 @@ export class SetupGate {
// §10.3.1.3: vi64 (full uint64) — emitted as a vi64 Setup Option below.
maxAuthTokenCacheSize?: bigint;
authTokens?: Uint8Array[];
+ /** draft-21 §9.1.7: our MAX_REQUEST_UPDATES; ignored before draft 21. */
+ maxRequestUpdates?: bigint;
} = {}): Setup {
const enc = new TextEncoder();
const setupOptions: SetupOptionMap = new Map();
@@ -221,6 +226,9 @@ export class SetupGate {
if (options.authTokens !== undefined && options.authTokens.length > 0) {
setupOptions.set(BigInt(SetupOption18.AUTHORIZATION_TOKEN), options.authTokens);
}
+ if (options.maxRequestUpdates !== undefined && isDraft21(this.draftVersion)) {
+ setupOptions.set(BigInt(SetupOption21.MAX_REQUEST_UPDATES), [options.maxRequestUpdates]);
+ }
// Deliberately NO MAX_REQUEST_ID for draft-18.
this.state = this.state === SessionState.IDLE ? SessionState.SETUP_PENDING : SessionState.ESTABLISHED;
return { type: 'SETUP', setupOptions };
@@ -267,6 +275,7 @@ export class SetupGate {
// the raw bigint without folding it through the QUIC-varint range.
let peerMaxAuthTokenCacheSize: bigint | undefined;
let authTokens: AuthorizationToken[] | undefined;
+ let peerMaxRequestUpdates: bigint | undefined;
for (const [type, values] of options) {
// §10.3 / §10.3.1: a known singleton Setup Option MUST NOT be repeated
@@ -298,6 +307,14 @@ export class SetupGate {
.filter((v): v is Uint8Array => v instanceof Uint8Array)
.map((v) => parseAuthorizationToken18(v));
break;
+ case SetupOption21.MAX_REQUEST_UPDATES:
+ // A draft-18 peer would not send it; there it is an unknown option.
+ if (!isDraft21(this.draftVersion)) break;
+ if (values.length > 1) {
+ throw new SetupError('Repeated Setup Option MAX_REQUEST_UPDATES (singleton)', 'PROTOCOL_VIOLATION');
+ }
+ if (typeof first === 'bigint') peerMaxRequestUpdates = first;
+ break;
// Unknown Setup Options are ignored (§10.3).
}
}
@@ -309,6 +326,7 @@ export class SetupGate {
...(authority !== undefined ? { authority } : {}),
...(peerMaxAuthTokenCacheSize !== undefined ? { peerMaxAuthTokenCacheSize } : {}),
...(authTokens !== undefined ? { authTokens } : {}),
+ ...(peerMaxRequestUpdates !== undefined ? { peerMaxRequestUpdates } : {}),
};
}
diff --git a/packages/transport/src/session/subscription.ts b/packages/transport/src/session/subscription.ts
index 02f1b483..6f30e191 100644
--- a/packages/transport/src/session/subscription.ts
+++ b/packages/transport/src/session/subscription.ts
@@ -19,6 +19,8 @@
import { varint, type Varint } from '../primitives/varint.js';
import type { Parameters } from '../control/messages.js';
+import type { GroupOrder } from '../data/types.js';
+import type { SubscriptionWindow } from '../control/subscription-filter.js';
import {
SubscriptionState,
ForwardState,
@@ -30,14 +32,22 @@ import {
* Location within a track (group + object ID).
*/
export interface Location {
- readonly groupId: Varint;
- readonly objectId: Varint;
+ readonly groupId: bigint;
+ readonly objectId: bigint;
}
/**
* Manages the state machine for a single subscription.
*/
export class SubscriptionStateMachine {
+ requestedGroupOrder: GroupOrder | undefined;
+ publisherGroupOrder: GroupOrder = 'ascending';
+ locationWindow: SubscriptionWindow | undefined;
+
+ get groupOrder(): GroupOrder {
+ return this.requestedGroupOrder ?? this.publisherGroupOrder;
+ }
+
private _state: SubscriptionStateValue = SubscriptionState.PENDING;
private _forwardState: ForwardStateValue = ForwardState.ACTIVE;
private _remoteFilterType: string | undefined;
@@ -69,6 +79,11 @@ export class SubscriptionStateMachine {
* @see draft-ietf-moq-transport-14 §9.10
*/
private _currentFilter: Uint8Array | undefined;
+ /**
+ * draft-21 §3.4: the SUBSCRIBE asked for a fill (FILL_PARAMETERS), so a fill
+ * fetch stream whose FETCH_HEADER carries this Request ID belongs to it.
+ */
+ fillRequested = false;
/**
* Last-sent SUBSCRIBER_PRIORITY for this subscription.
* Used by draft-14 to replay unchanged values in SUBSCRIBE_UPDATE.
@@ -514,7 +529,7 @@ export class SubscriptionStateMachine {
* Update largest location seen on this subscription.
* Only updates if the new location is larger.
*/
- updateLargestLocation(groupId: Varint, objectId: Varint): void {
+ updateLargestLocation(groupId: bigint, objectId: bigint): void {
if (!this._largestLocation) {
this._largestLocation = { groupId, objectId };
return;
diff --git a/packages/transport/src/session/track-alias.ts b/packages/transport/src/session/track-alias.ts
index d87f2b7a..30f4085e 100644
--- a/packages/transport/src/session/track-alias.ts
+++ b/packages/transport/src/session/track-alias.ts
@@ -173,6 +173,11 @@ export class TrackAliasManager {
return this.aliasToTrack.has(alias as bigint);
}
+ /** Request identities retaining this alias, without transferring ownership. */
+ owners(alias: bigint): Iterable {
+ return this.aliasToOwner.get(alias) ?? [];
+ }
+
/**
* Check if a track is registered.
*
diff --git a/packages/transport/src/vectors/vectors-d21.test.ts b/packages/transport/src/vectors/vectors-d21.test.ts
new file mode 100644
index 00000000..fe0c53a8
--- /dev/null
+++ b/packages/transport/src/vectors/vectors-d21.test.ts
@@ -0,0 +1,143 @@
+/**
+ * Draft-21 cross-implementation wire vectors.
+ *
+ * `packages/transport/vectors/d21/wire-vectors.txt` is shared with red5-moq-relay:
+ * the vectors were produced by (or verified against) moqxr's draft-21 codec, so
+ * decoding them here checks Playa against two other implementations. Every
+ * control vector must decode with the draft-21 codec and re-encode to the same
+ * bytes; the frames playback depends on are also checked field by field.
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { vectorsDir, bytesToHex, hexToBytes } from './load-vectors.js';
+import { createControlCodec } from '../control/codec.js';
+import { decodeMessageParams18, DRAFT21_MESSAGE_PARAM_REGISTRY } from '../control/message-params-18.js';
+import { decodeLocationFilterFields } from '../control/subscription-filter.js';
+import { decodeFetchObject18 } from '../data/decoder-18.js';
+import type {
+ ControlMessage, Fetch, FetchOk, Goaway, PublishStateNotify, Subscribe, SubscribeOk, PublishDone,
+} from '../control/messages.js';
+
+interface Vector {
+ readonly name: string;
+ readonly kind: 'control' | 'parameter' | 'stream';
+ readonly bytes: Uint8Array;
+}
+
+function loadD21(): Map {
+ const text = readFileSync(join(vectorsDir('d21'), 'wire-vectors.txt'), 'utf8');
+ const vectors = new Map();
+ for (const line of text.split('\n')) {
+ const trimmed = line.trim();
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
+ const [name, kind, , hex] = trimmed.split(/\s+/);
+ vectors.set(name!, { name: name!, kind: kind as Vector['kind'], bytes: hexToBytes(hex!) });
+ }
+ return vectors;
+}
+
+const VECTORS = loadD21();
+const codec21 = createControlCodec(21);
+const vector = (name: string): Uint8Array => {
+ const v = VECTORS.get(name);
+ if (v === undefined) throw new Error(`no draft-21 vector ${name}`);
+ return v.bytes;
+};
+const decode = (name: string): T => codec21.decode(vector(name), 0).message as T;
+const LOCATION_FILTER = 0x21n;
+const FILL_PARAMETERS = 0x23n;
+const INCLUDE_PROPERTIES = 0x35n;
+const LARGEST_OBJECT = 0x09n;
+const FORWARD = 0x10n;
+
+describe('draft-21 wire vectors (shared with red5-moq-relay and moqxr)', () => {
+ it('loads the full set', () => {
+ expect(VECTORS.size).toBeGreaterThanOrEqual(29);
+ });
+
+ for (const v of VECTORS.values()) {
+ if (v.kind !== 'control') continue;
+ it(`${v.name} decodes and re-encodes byte for byte`, () => {
+ const { message, bytesRead } = codec21.decode(v.bytes, 0);
+ expect(bytesRead).toBe(v.bytes.length);
+ expect(bytesToHex(codec21.encode(message))).toBe(bytesToHex(v.bytes));
+ });
+ }
+
+ it('SUBSCRIBE LOCATION_FILTER forms', () => {
+ const filter = (name: string) =>
+ decodeLocationFilterFields(decode(name).parameters.get(LOCATION_FILTER)![0] as Uint8Array);
+ expect(filter('subscribe_location_filter_empty')).toEqual([]);
+ expect(filter('subscribe_location_filter_next_group')).toEqual([0n]);
+ expect(filter('subscribe_location_filter_next_object')).toEqual([0n, 0n]);
+ expect(filter('subscribe_location_filter_relative_3')).toEqual([3n]);
+ expect(filter('subscribe_location_filter_start_12_5')).toEqual([12n, 5n]);
+ expect(filter('subscribe_location_filter_range_12_5_delta_3')).toEqual([12n, 5n, 3n]);
+ expect(filter('subscribe_location_filter_range_12_5_delta_3_end_object_7')).toEqual([12n, 5n, 3n, 7n]);
+ });
+
+ it('SUBSCRIBE that joins the current group with a fill (the catalog recipe)', () => {
+ const sub = decode('subscribe_join_current_group_with_fill_include_properties_0');
+ expect(decodeLocationFilterFields(sub.parameters.get(LOCATION_FILTER)![0] as Uint8Array)).toEqual([0n, 0n]);
+ // FILL_PARAMETERS: a bare parameter sequence holding LOCATION_FILTER [1] (the current group).
+ expect(bytesToHex(sub.parameters.get(FILL_PARAMETERS)![0] as Uint8Array)).toBe('210101');
+ expect(sub.parameters.get(INCLUDE_PROPERTIES)).toEqual([0n]);
+ });
+
+ it('SUBSCRIBE_OK carries LARGEST_OBJECT when content exists', () => {
+ expect(decode('subscribe_ok_no_largest').parameters.has(LARGEST_OBJECT)).toBe(false);
+ expect(decode('subscribe_ok_largest_3_7').parameters.get(LARGEST_OBJECT)).toEqual([{ group: 3n, object: 7n }]);
+ });
+
+ it('FETCH carries its range as a LOCATION_FILTER with an inclusive End Object', () => {
+ const fetch = decode('fetch_range_12_5_to_15_9');
+ expect(fetch.fetch).toMatchObject({
+ fetchType: 0x1,
+ startLocation: { group: 12n, object: 5n },
+ // the draft-18 model's End Object is exclusive (9 inclusive becomes 10)
+ endLocation: { group: 15n, object: 10n },
+ });
+ });
+
+ it('FETCH_OK End Location', () => {
+ expect(decode('fetch_ok_inclusive_end_15_9').endLocation).toEqual({ group: 15n, object: 9n });
+ });
+
+ it('GOAWAY has no Request ID', () => {
+ const goaway = decode('goaway_no_uri_timeout_5000');
+ expect(goaway).toMatchObject({ newSessionUri: '', timeout: 5000n });
+ expect(goaway.requestId).toBeUndefined();
+ });
+
+ it('PUBLISH_STATE_NOTIFY reports LARGEST_OBJECT and FORWARD', () => {
+ const notify = decode('publish_state_notify_largest_3_7_forward_0');
+ expect(notify.type).toBe('PUBLISH_STATE_NOTIFY');
+ expect(notify.parameters.get(LARGEST_OBJECT)).toEqual([{ group: 3n, object: 7n }]);
+ expect(notify.parameters.get(FORWARD)).toEqual([0n]);
+ });
+
+ it('PUBLISH_DONE with an unknown Stream Count (2^64-1)', () => {
+ const done = decode('publish_done_track_ended_unknown_count');
+ expect(done.streamCount).toBe((1n << 64n) - 1n);
+ });
+
+ it('End of Timed-Out Range: Group ID and Object ID, no payload length', () => {
+ const bytes = vector('fetch_object_end_of_timed_out_range_g2_o4');
+ const { item, bytesRead } = decodeFetchObject18(bytes, 0, undefined, true, 'ascending', 21);
+ expect(bytesRead).toBe(bytes.length);
+ expect(item).toMatchObject({ flags: 0x20cn, groupId: 2n, objectId: 4n, nonExistent: false });
+ expect(() => decodeFetchObject18(bytes, 0, undefined, true, 'ascending', 18)).toThrow();
+ });
+
+ it('range-filter parameters are known to the draft-21 registry', () => {
+ for (const v of VECTORS.values()) {
+ if (v.kind !== 'parameter') continue;
+ const counted = new Uint8Array(v.bytes.length + 1);
+ counted[0] = 1;
+ counted.set(v.bytes, 1);
+ const { bytesRead } = decodeMessageParams18(counted, 0, DRAFT21_MESSAGE_PARAM_REGISTRY);
+ expect(bytesRead, v.name).toBe(counted.length);
+ }
+ });
+});
diff --git a/packages/transport/src/versions.test.ts b/packages/transport/src/versions.test.ts
new file mode 100644
index 00000000..51ad4e04
--- /dev/null
+++ b/packages/transport/src/versions.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest';
+import { isDraft21, isRequestStreamDraft, isWiredDraft, WIRED_DRAFTS } from './versions.js';
+
+describe('draft versions', () => {
+ it('wires drafts 14, 16, 18 and 21 only', () => {
+ expect(WIRED_DRAFTS).toEqual([14, 16, 18, 21]);
+ for (const v of [14, 16, 18, 21]) expect(isWiredDraft(v)).toBe(true);
+ for (const v of [15, 17, 19, 20, 22]) expect(isWiredDraft(v)).toBe(false);
+ });
+
+ it('uses the request-stream model from draft 18 on', () => {
+ expect(isRequestStreamDraft(14)).toBe(false);
+ expect(isRequestStreamDraft(16)).toBe(false);
+ expect(isRequestStreamDraft(18)).toBe(true);
+ expect(isRequestStreamDraft(21)).toBe(true);
+ expect(isRequestStreamDraft(undefined)).toBe(false);
+ });
+
+ it('identifies draft 21', () => {
+ expect(isDraft21(18)).toBe(false);
+ expect(isDraft21(21)).toBe(true);
+ expect(isDraft21(undefined)).toBe(false);
+ });
+});
diff --git a/packages/transport/src/versions.ts b/packages/transport/src/versions.ts
index d02d450c..2f5124f2 100644
--- a/packages/transport/src/versions.ts
+++ b/packages/transport/src/versions.ts
@@ -15,13 +15,30 @@
* - `16` — draft-ietf-moq-transport-16 (default)
* - `18` — draft-ietf-moq-transport-18 (fully wired: control + data codecs,
* uni-pair topology, request profile)
+ * - `21` — draft-ietf-moq-transport-21 (the draft-18 stream model with
+ * LOCATION_FILTER, fill fetch streams, PUBLISH_STATE_NOTIFY and a
+ * FETCH that carries its range as a LOCATION_FILTER)
*/
-export type DraftVersion = 14 | 16 | 18;
+export type DraftVersion = 14 | 16 | 18 | 21;
/** Draft versions with a fully-wired wire codec today. */
-export const WIRED_DRAFTS: readonly DraftVersion[] = [14, 16, 18];
+export const WIRED_DRAFTS: readonly DraftVersion[] = [14, 16, 18, 21];
/** Whether `v` has a fully-wired control + data codec. */
export function isWiredDraft(v: number): v is DraftVersion {
- return v === 14 || v === 16 || v === 18;
+ return v === 14 || v === 16 || v === 18 || v === 21;
+}
+
+/**
+ * Whether `v` uses the draft-18 stream model: a unified SETUP on a uni
+ * control-stream pair, one bidi stream per request, stream-correlated
+ * responses and vi64 integers. True for draft 18 and every later draft.
+ */
+export function isRequestStreamDraft(v: number | undefined): boolean {
+ return v !== undefined && v >= 18;
+}
+
+/** Whether `v` is draft 21 or later (LOCATION_FILTER, fills, no Joining FETCH). */
+export function isDraft21(v: number | undefined): boolean {
+ return v !== undefined && v >= 21;
}
diff --git a/packages/transport/vectors/d21/wire-vectors.txt b/packages/transport/vectors/d21/wire-vectors.txt
new file mode 100644
index 00000000..6010e62e
--- /dev/null
+++ b/packages/transport/vectors/d21/wire-vectors.txt
@@ -0,0 +1,39 @@
+# Draft-21 (draft-ietf-moq-transport-21, version 0xff000015, ALPN moqt-21) wire vectors.
+# Format:
+# kind: control = one framed control message {Type vi64, Length (16), Body}
+# parameter = one message parameter {Type, Length, Value}
+# stream = bytes at the start of a data/fetch stream
+# source: moqxr-encoder = produced by moqxr (openmoq/moqxr#45, feature/draft-21 @ 478d6c0)
+# moqxr-decoder-verified = hand-built, accepted by moqxr's draft-21 decoders
+# draft-text = hand-built from docs/draft-ietf-moq-transport-21.txt only
+# Copied from red5-moq-relay (src/test/resources/draft21/wire-vectors.txt), which regenerates
+# them with scripts/draft21-vectors/gen.cpp against moqxr. Keep the vector lines identical.
+client_setup control moqxr-encoder af00001901042f6d6f71041172656c61792e6578616d706c652e636f6d
+publish_namespace control moqxr-encoder 0600080001046c69766500
+publish control moqxr-encoder 1d000f0201046c69766505766964656f0100
+subscribe_ok_no_largest control moqxr-encoder 0400020100
+subscribe_ok_largest_3_7 control moqxr-encoder 0400050101090307
+request_update_ok_largest_3_7 control moqxr-encoder 07000401090307
+publish_done_track_ended_unknown_count control moqxr-encoder 0b000f02ffffffffffffffffff04646f6e65
+fetch_header_fill_req_95 stream moqxr-encoder 055f
+subgroup_header_alias1_g3_sg0 stream moqxr-encoder 300103
+server_setup_filter_ranges_16_request_updates_4 control moqxr-decoder-verified af00000406100204
+subscribe_location_filter_empty control moqxr-decoder-verified 0300100901046c69766505766964656f012100
+subscribe_location_filter_next_group control moqxr-decoder-verified 0300110901046c69766505766964656f01210100
+subscribe_location_filter_relative_3 control moqxr-decoder-verified 0300110901046c69766505766964656f01210103
+subscribe_location_filter_next_object control moqxr-decoder-verified 0300120901046c69766505766964656f0121020000
+subscribe_location_filter_start_12_5 control moqxr-decoder-verified 0300120901046c69766505766964656f0121020c05
+subscribe_location_filter_range_12_5_delta_3 control moqxr-decoder-verified 0300130901046c69766505766964656f0121030c0503
+subscribe_location_filter_range_12_5_delta_3_end_object_7 control moqxr-decoder-verified 0300140901046c69766505766964656f0121040c050307
+subscribe_join_current_group_with_fill_include_properties_0 control moqxr-decoder-verified 0300190901046c69766505766964656f032102000002032101011200
+subscribe_tracks_next_object_group_order_descending control moqxr-decoder-verified 51000e5d01046c69766502210200000102
+request_update_fill_whole_track control moqxr-decoder-verified 0200045f012300
+publish_ok_expires_30000 control moqxr-decoder-verified 0700050108c07530
+fetch_range_12_5_to_15_9 control draft-text 1600140701046c69766505766964656f0121040c050309
+fetch_ok_inclusive_end_15_9 control draft-text 180004000f0900
+goaway_no_uri_timeout_5000 control draft-text 100003009388
+publish_state_notify_largest_3_7_forward_0 control draft-text 220006020903070700
+publish_skipped_cam2_video control draft-text 0f000c010463616d3205766964656f
+param_subgroup_filter_set0_3to5_10to15 parameter draft-text 25050003020505
+param_object_property_filter_set1_prop16_from100 parameter draft-text 2803011064
+fetch_object_end_of_timed_out_range_g2_o4 stream draft-text 820c0204
diff --git a/packages/webtransport/src/adapter-d21-conformance.test.ts b/packages/webtransport/src/adapter-d21-conformance.test.ts
new file mode 100644
index 00000000..392ffa8e
--- /dev/null
+++ b/packages/webtransport/src/adapter-d21-conformance.test.ts
@@ -0,0 +1,788 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ createControlCodec,
+ SessionState,
+ varint,
+ type MoqtObject,
+ type Parameters,
+ type ControlMessage,
+} from '@openmoq/transport';
+import { MoqtConnection } from './adapter.js';
+import { connectedPair, nm, ns, type ConnectedPair } from './testkit/pair.js';
+import { TransportSim, flush } from './testkit/stream-sim.js';
+
+const connections: MoqtConnection[] = [];
+const codec = createControlCodec(21);
+const ALIAS = 50n;
+
+afterEach(async () => {
+ for (const conn of connections.splice(0)) await conn.close();
+});
+
+async function settle(): Promise {
+ for (let i = 0; i < 10; i++) await flush();
+}
+
+async function pair(draft: 18 | 21 = 21): Promise {
+ const result = await connectedPair(draft);
+ connections.push(result.client, result.server);
+ return result;
+}
+
+function serveSubscriptions({ server, errors }: ConnectedPair): void {
+ server.setLargestLocationProvider(() => ({ group: 5n, object: 0n }));
+ server.onSubscribe = (requestId) => {
+ const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]);
+ void server.acceptSubscribe(requestId, ALIAS, { parameters }).catch((err: Error) => errors.push(err));
+ };
+}
+
+async function publish(server: MoqtConnection, group: bigint, requestId?: bigint): Promise {
+ const stream = await server.openSubgroup(ALIAS, group, 0n, { publisherPriority: 1, ...(requestId === undefined ? {} : { requestId }) });
+ await server.sendObject(stream, 0n, new Uint8Array([1]));
+ await server.closeSubgroup(stream);
+ await settle();
+}
+
+describe('draft-21 GOAWAY through the public adapter', () => {
+ it.each(['client', 'server'] as const)('%s sends GOAWAY without a Request ID (9.2)', async (role) => {
+ const p = await pair();
+ const sender = p[role];
+ const receiver = role === 'client' ? p.server : p.client;
+
+ await expect(sender.sendGoaway({ timeout: 1000n })).resolves.toBeUndefined();
+ await settle();
+
+ expect(receiver.session.state).toBe(SessionState.DRAINING);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('draft 18 still requires a Request ID with the peer parity', async () => {
+ const { client, server, errors } = await pair(18);
+
+ await expect(client.sendGoaway()).rejects.toThrow(/requires a Request ID/);
+ await expect(client.sendGoaway({ requestId: 0n })).rejects.toThrow(/wrong parity/);
+ await client.sendGoaway({ requestId: 1n });
+ await settle();
+
+ expect(server.session.state).toBe(SessionState.DRAINING);
+ expect(errors).toEqual([]);
+ });
+
+ it('draft 21 still prohibits a client from redirecting the server', async () => {
+ const { client, server } = await pair();
+ await expect(client.sendGoaway({ newSessionUri: 'https://relay.example/moq', requestId: 1n }))
+ .rejects.toThrow(/client MUST send a zero-length/);
+ expect(server.session.state).toBe(SessionState.ESTABLISHED);
+ });
+});
+
+describe('draft-21 SUBSCRIBE_TRACKS Forward through the public adapter', () => {
+ it('inherits the accepted update only on an explicitly associated PUBLISH', async () => {
+ const p = await pair();
+ p.server.onSubscribeTracks = (id) => {
+ void p.server.acceptSubscribeTracks(id).catch((e: Error) => p.errors.push(e));
+ };
+ const forwards: number[] = [];
+ p.client.onPublish = (pub) => {
+ forwards.push(p.client.session.getIncomingSubscription(pub.requestId)!.forwardState);
+ void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e));
+ };
+ const id = await p.client.subscribeTracks(ns('live'));
+ await settle();
+ await p.client.requestUpdate(id, { forward: 0 });
+ await settle();
+ await p.server.publish(ns('live'), nm('video'), ALIAS, { subscribeTracksRequestId: id });
+ await settle();
+ await p.server.publish(ns('live'), nm('audio'), ALIAS + 1n, { parameters: new Map([[0x10n, [1n]]]) });
+ await settle();
+ expect(forwards).toEqual([0, 1]);
+ expect(p.errors).toEqual([]);
+ });
+});
+
+describe('draft-21 fill group order (3.4 and 9.20.9)', () => {
+ it('inherits the publisher Group Order advertised in SUBSCRIBE_OK', async () => {
+ const p = await pair();
+ p.server.onSubscribe = (id) => { void p.server.acceptSubscribe(id, ALIAS, {
+ parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map([[0x22n, [2n]]]),
+ }); };
+ const groups: bigint[] = [];
+ p.client.onObject = (_sid, o) => groups.push(o.groupId);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ for (const groupId of [5n, 4n]) await p.server.sendFetchObject(stream, {
+ groupId, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]),
+ });
+ await p.server.closeFetchStream(stream);
+ await settle();
+ expect(groups).toEqual([5n, 4n]);
+ expect(p.errors).toEqual([]);
+ });
+ it('a fill override takes precedence over the subscription order', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const objects: MoqtObject[] = [];
+ p.client.onObject = (_stream, object) => objects.push(object);
+ const id = await p.client.subscribe(ns('live'), nm('video'), {
+ groupOrder: varint(1n), fill: { groupOrder: 'descending' },
+ });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) };
+ await p.server.sendFetchObject(stream, { ...fields, groupId: 5n });
+ await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 4n })).resolves.toBeUndefined();
+ await p.server.closeFetchStream(stream);
+ await settle();
+ expect(objects.map((o) => o.groupId)).toEqual([5n, 4n]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('Forward=0 does not authorize a fill, even after a later resume', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { forward: 0, fill: {} });
+ await settle();
+ expect(p.client.session.fillSubscriptionFor(id)).toBeUndefined();
+ await p.client.requestUpdate(id, { forward: 1 });
+ await settle();
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/);
+ expect(p.errors).toEqual([]);
+ });
+
+ it.each([
+ { order: 1n, groups: [5n, 6n] },
+ { order: 2n, groups: [5n, 4n] },
+ ])('decodes a fill with inherited GROUP_ORDER=$order', async ({ order, groups }) => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ const objects: MoqtObject[] = [];
+ const errors: Error[] = [];
+ conn.onObject = (_stream, object) => objects.push(object);
+ conn.onError = (err) => errors.push(err);
+ await conn.connect(transport);
+
+ const requestId = await conn.subscribe(ns('live'), nm('video'), { groupOrder: varint(order), fill: {} });
+ expect(requestId).toBe(0n);
+ await settle();
+ transport.bidi[0]!.push(codec.encode({
+ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS,
+ parameters: new Map([[0x09n, [{ group: 6n, object: 0n }]]]), trackExtensions: new Map(),
+ }));
+ await settle();
+
+ // 11.4.1: a literal FETCH_HEADER followed by groups 5 and delta 0.
+ // The second group is 6 ascending, 4 descending. This fixture does not
+ // use our object encoder, so matching encoder/decoder mistakes cannot pass.
+ transport.pushIncomingUni(new Uint8Array([
+ 0x05, 0x00,
+ 0x1c, 0x05, 0x00, 0x03, 0x01, 0xa5,
+ 0x0c, 0x00, 0x00, 0x01, 0xa4,
+ ]));
+ await settle();
+
+ expect(errors).toEqual([]);
+ expect(objects.map((o) => o.groupId)).toEqual(groups);
+ });
+
+ it('publishes consecutive descending groups on a fill', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const objects: MoqtObject[] = [];
+ p.client.onObject = (_stream, object) => objects.push(object);
+ const requestId = await p.client.subscribe(ns('live'), nm('video'), { groupOrder: varint(2n), fill: {} });
+ await settle();
+ const stream = await p.server.openFillStream(requestId);
+ const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) };
+
+ await p.server.sendFetchObject(stream, { ...fields, groupId: 5n });
+ await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 4n })).resolves.toBeUndefined();
+ await p.server.closeFetchStream(stream);
+ await settle();
+
+ expect(objects.map((o) => o.groupId)).toEqual([5n, 4n]);
+ expect(p.errors).toEqual([]);
+ });
+});
+
+describe('draft-21 shared-alias subscription ownership (3.1)', () => {
+ it.each(['stream', 'datagram'] as const)('does not send %s objects outside the accepted Location Filter', async (delivery) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), {
+ subscriptionFilter: { type: 'AbsoluteRange', startGroup: 2n, startObject: 1n, endGroup: 2n, endObject: 3n },
+ });
+ await settle();
+ if (delivery === 'stream') {
+ const stream = await p.server.openSubgroup(ALIAS, 2n, 0n);
+ await expect(p.server.sendObject(stream, 0n, new Uint8Array([1]))).rejects.toThrow(/Location Filter/);
+ await p.server.sendObject(stream, 1n, new Uint8Array([1]));
+ await p.server.closeSubgroup(stream);
+ } else {
+ await expect(p.server.sendDatagram(ALIAS, 2n, 0n, new Uint8Array([1]))).rejects.toThrow(/Location Filter/);
+ await p.server.sendDatagram(ALIAS, 2n, 1n, new Uint8Array([1]));
+ }
+ expect(p.server.session.getIncomingSubscription(id)).toBeDefined();
+ expect(p.errors).toEqual([]);
+ });
+ it('drains each shared subscription and sums their terminal stream counts', async () => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ await conn.connect(transport);
+ const objects: bigint[] = [];
+ conn.onObject = (_sid, o) => objects.push(o.groupId);
+ const drained: bigint[] = [];
+ for (let i = 0; i < 2; i++) {
+ const requestId = await conn.subscribe(ns('live'), nm('video'), {
+ subscriptionFilter: { type: 'AbsoluteRange', startGroup: BigInt(i + 1), startObject: 0n, endGroup: BigInt(i + 1) },
+ terminalDelivery: 'drain', onDrained: (id) => drained.push(id),
+ });
+ await settle();
+ transport.bidi[i]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS,
+ parameters: new Map(), trackProperties: new Map(),
+ }));
+ await settle();
+ }
+ for (let i = 0; i < 2; i++) {
+ transport.bidi[i]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: BigInt(i * 2), statusCode: 2n, streamCount: 1n, errorReason: 'done' }));
+ await settle();
+ expect(drained).toEqual([]);
+ transport.pushIncomingUni(new Uint8Array([0x10, Number(ALIAS), i + 1, 1, 0, 1, 1]));
+ await settle();
+ }
+ expect(objects).toEqual([1n, 2n]);
+ });
+ it('delivers subgroup FIN to both SUBSCRIBE and PUBLISH owners', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const closed: string[] = [];
+ const sub = await p.client.subscribeTrack(ns('live'), nm('video'), {
+ onSubgroupClosed: () => closed.push('subscribe'),
+ });
+ p.client.onPublish = (pub) => {
+ pub.onSubgroupClosed = () => closed.push('publish');
+ void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e));
+ };
+ await p.server.publish(ns('live'), nm('video'), ALIAS);
+ await settle();
+ await publish(p.server, 6n, sub.requestId);
+ expect(closed).toEqual(['subscribe', 'publish']);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('does not call another alias owner after a callback closes the connection', async () => {
+ const p = await pair();
+ const objects: bigint[] = [];
+ let count = 0;
+ p.client.onPublish = (pub) => {
+ const first = count++ === 0;
+ pub.onObject = () => {
+ objects.push(pub.requestId);
+ if (first) void p.client.close();
+ };
+ void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e));
+ };
+ const first = await p.server.publish(ns('live'), nm('video'), ALIAS);
+ const second = await p.server.publish(ns('live'), nm('video'), ALIAS);
+ await settle();
+ const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: second });
+ await p.server.sendObject(stream, 0n, new Uint8Array([1]));
+ await settle();
+ expect(objects).toEqual([first]);
+ });
+
+ it('honors a reentrant cancellation before calling the next shared owner', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const first = await p.client.subscribeTrack(ns('live'), nm('video'));
+ const objects: bigint[] = [];
+ const second = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => objects.push(o.objectId) });
+ first.onObject = () => { void second.unsubscribe(); };
+ await publish(p.server, 6n, first.requestId);
+ expect(objects).toEqual([]);
+ });
+ it('keeps each inbound PUBLISH owner when its peer on the same alias ends', async () => {
+ const p = await pair();
+ const delivered: bigint[][] = [];
+ p.client.onPublish = (pub) => {
+ const objects: bigint[] = [];
+ delivered.push(objects);
+ pub.onObject = (o) => objects.push(o.objectId);
+ void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e));
+ };
+ const first = await p.server.publish(ns('live'), nm('video'), ALIAS);
+ const second = await p.server.publish(ns('live'), nm('video'), ALIAS);
+ await settle();
+ const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: second });
+ await p.server.sendObject(stream, 0n, new Uint8Array([1]));
+ await settle();
+ expect(delivered).toEqual([[0n], [0n]]);
+ await publish(p.server, 6n, first);
+ expect(delivered).toEqual([[0n], [0n]]);
+ await p.server.publishDone(first, varint(2n), 'done');
+ await settle();
+ await p.server.sendObject(stream, 1n, new Uint8Array([1]));
+ await p.server.closeSubgroup(stream);
+ await settle();
+ expect(delivered).toEqual([[0n], [0n, 1n]]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it.each(['raw', 'generic'] as const)('ending the %s member preserves a mixed shared alias', async (retired) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const rawObjects: bigint[] = [];
+ const genericObjects: bigint[] = [];
+ p.client.onObject = (_sid, object) => genericObjects.push(object.objectId);
+ const raw = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => rawObjects.push(o.objectId) });
+ const generic = await p.client.subscribe(ns('live'), nm('video'));
+ await settle();
+ const survivor = retired === 'raw' ? generic : raw.requestId;
+ const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: survivor });
+ await p.server.sendObject(stream, 0n, new Uint8Array([1]));
+ await settle();
+ expect(rawObjects).toEqual([0n]);
+ expect(genericObjects).toEqual([0n]);
+ await p.client.unsubscribe(retired === 'raw' ? raw.requestId : generic);
+ await p.server.sendObject(stream, 1n, new Uint8Array([1]));
+ await p.server.closeSubgroup(stream);
+ await settle();
+ expect(retired === 'raw' ? genericObjects : rawObjects).toEqual([0n, 1n]);
+ expect(retired === 'raw' ? rawObjects : genericObjects).toEqual([0n]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it.each([0, 1])('cancelling member %i resets only its already-open streams', async (retired) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const delivered: bigint[][] = [[], []];
+ const first = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => delivered[0]!.push(o.objectId) });
+ const second = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => delivered[1]!.push(o.objectId) });
+ const subscribers = [first, second];
+ const streams: bigint[] = [];
+ for (const sub of subscribers) {
+ streams.push(await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: sub.requestId }));
+ }
+ for (const stream of streams) await p.server.sendObject(stream, 0n, new Uint8Array([1]));
+ await settle();
+ expect(p.server.session.getIncomingSubscription(first.requestId)!.streamCount).toBe(1n);
+ expect(p.server.session.getIncomingSubscription(second.requestId)!.streamCount).toBe(1n);
+ await subscribers[retired]!.unsubscribe();
+ await settle();
+ await expect(p.server.sendObject(streams[retired]!, 0n, new Uint8Array([1]))).rejects.toThrow();
+ await expect(p.server.sendObject(streams[1 - retired]!, 1n, new Uint8Array([1]))).resolves.toBeUndefined();
+ await p.server.closeSubgroup(streams[1 - retired]!);
+ await settle();
+ expect(delivered[retired]).toEqual([0n]);
+ expect(delivered[1 - retired]).toEqual([0n, 1n]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('requires an explicit request when publisher stream ownership is ambiguous', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ await p.client.subscribeTrack(ns('live'), nm('video'));
+ await p.client.subscribeTrack(ns('live'), nm('video'));
+ await expect(p.server.openSubgroup(ALIAS, 6n, 0n)).rejects.toThrow(/requestId/);
+ });
+
+ it('uses the accepted updated Location Filter for attribution', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const a: MoqtObject[] = [];
+ const b: MoqtObject[] = [];
+ const first = await p.client.subscribeTrack(ns('live'), nm('video'), {
+ filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n },
+ onObject: (object) => a.push(object),
+ });
+ const second = await p.client.subscribeTrack(ns('live'), nm('video'), {
+ filter: { type: 'AbsoluteStart', startGroup: 2n, startObject: 0n },
+ onObject: (object) => b.push(object),
+ });
+ await publish(p.server, 1n, first.requestId);
+ expect(a.map((o) => o.groupId)).toEqual([1n]);
+ expect(b).toEqual([]);
+
+ const responses: string[] = [];
+ p.client.onMessage = (message) => responses.push(message.type);
+ await p.client.requestUpdate(first.requestId, {
+ subscriptionFilter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 2n },
+ });
+ await settle();
+ expect(responses).toContain('REQUEST_OK');
+ await publish(p.server, 2n, first.requestId);
+ await publish(p.server, 2n, second.requestId);
+
+ expect(p.errors).toEqual([]);
+ expect(b.map((o) => o.groupId)).toEqual([2n]);
+ expect(a.map((o) => o.groupId)).toEqual([1n, 2n]);
+ });
+
+ it.each([0, 1])('unsubscribing member %i preserves the other member', async (retired) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const delivered: MoqtObject[][] = [[], []];
+ const first = await p.client.subscribeTrack(ns('live'), nm('video'), {
+ onObject: (object) => delivered[0]!.push(object),
+ });
+ const second = await p.client.subscribeTrack(ns('live'), nm('video'), {
+ onObject: (object) => delivered[1]!.push(object),
+ });
+
+ await [first, second][retired]!.unsubscribe();
+ await settle();
+ await publish(p.server, 6n);
+
+ expect(p.errors).toEqual([]);
+ expect(delivered[retired]).toEqual([]);
+ expect(delivered[1 - retired]!.map((o) => o.groupId)).toEqual([6n]);
+ });
+});
+
+describe('draft-21 fill lifecycle (3.4 and 9.9)', () => {
+ it('reclaims empty publisher fills across repeated updates', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { subscriptionFilter: { type: 'NextGroupStart' }, fill: {} });
+ await settle();
+ for (let i = 0; i < 3; i++) {
+ await p.client.requestUpdate(id, { fill: {} });
+ await settle();
+ }
+ const state = p.server as unknown as { inboundFillRequests: Map };
+ expect(state.inboundFillRequests.size).toBe(0);
+ expect(p.client.session.expectedFillsOf(id)).toEqual([]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('bounds unserved publisher fills across sequential updates', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ for (let i = 0; i < 255; i++) {
+ await p.client.requestUpdate(id, { fill: {} });
+ await settle();
+ expect(p.server.session.state).toBe(SessionState.ESTABLISHED);
+ }
+ await p.client.requestUpdate(id, { fill: {} });
+ await settle();
+ expect(p.server.session.state).toBe(SessionState.CLOSED);
+ });
+ it('keeps a draining fill when a shared inbound PUBLISH is cancelled', async () => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ const groups: bigint[] = [];
+ const errors: Error[] = [];
+ conn.onObject = (_sid, o) => groups.push(o.groupId);
+ conn.onError = (e) => errors.push(e);
+ conn.onPublish = (pub) => { void conn.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => errors.push(e)); };
+ await conn.connect(transport);
+ const id = await conn.subscribe(ns('live'), nm('video'), { fill: {}, terminalDelivery: 'drain' });
+ transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: ALIAS,
+ parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map(),
+ }));
+ const pub = transport.pushIncomingBidi();
+ pub.push(codec.encode({ type: 'PUBLISH', requestId: 1n, trackAlias: ALIAS,
+ trackNamespace: ns('live'), trackName: nm('video'), parameters: new Map(), trackProperties: new Map(),
+ }));
+ await settle();
+ transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: id, statusCode: 2n, streamCount: 1n, errorReason: 'done' }));
+ await settle();
+ pub.closeReadable();
+ await settle();
+ transport.pushIncomingUni(new Uint8Array([5, 0, 0x1c, 5, 0, 3, 1, 0xa5]));
+ await settle();
+ expect(groups).toEqual([5n]);
+ expect(errors).toEqual([]);
+ });
+ it('counts late fills against the terminal drain cap', async () => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ await conn.connect(transport);
+ const objects: bigint[] = [];
+ conn.onObject = (_sid, o) => objects.push(o.groupId);
+ const requestId = await conn.subscribe(ns('live'), nm('video'), { fill: {}, terminalDelivery: 'drain' });
+ transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS,
+ parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map(),
+ }));
+ await settle();
+ const update = await conn.requestUpdate(requestId, { fill: {} });
+ expect(update).toBe(2n);
+ transport.bidi[0]!.push(codec.encode({ type: 'REQUEST_OK', requestId: update, parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]) }));
+ await settle();
+ transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId, statusCode: 2n, streamCount: 1n, errorReason: 'done' }));
+ await settle();
+ transport.pushIncomingUni(new Uint8Array([5, 0, 0x1c, 5, 0, 3, 1, 0xa5]));
+ transport.pushIncomingUni(new Uint8Array([5, 2, 0x1c, 4, 0, 3, 1, 0xa4]));
+ await settle();
+ expect(objects).toEqual([5n]);
+ });
+ it.each(['discard', 'drain'] as const)('handles a fill crossing PUBLISH_DONE with %s delivery', async (terminalDelivery) => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ const objects: MoqtObject[] = [];
+ const errors: Error[] = [];
+ conn.onObject = (_sid, o) => objects.push(o);
+ conn.onError = (e) => errors.push(e);
+ await conn.connect(transport);
+ const id = await conn.subscribe(ns('live'), nm('video'), { fill: {}, ...(terminalDelivery === 'drain' ? { terminalDelivery } : {}) });
+ transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: ALIAS,
+ parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackExtensions: new Map(),
+ }));
+ await settle();
+ transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: id, statusCode: varint(2n), streamCount: 1n, errorReason: 'done' }));
+ await settle();
+ transport.pushIncomingUni(new Uint8Array([0x05, 0, 0x1c, 5, 0, 3, 1, 0xa5]));
+ await settle();
+ expect(errors).toEqual([]);
+ expect(objects.map((o) => o.groupId)).toEqual(terminalDelivery === 'drain' ? [5n] : []);
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('revokes a pending fill open when an update terminates its subscription', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ let release!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const create = p.b.createUnidirectionalStream.bind(p.b);
+ p.b.createUnidirectionalStream = async () => { await gate; return create(); };
+ const opening = p.server.openFillStream(id).then(() => 'opened', () => 'rejected');
+ p.server.setLargestLocationProvider(() => { throw new Error('unavailable'); });
+ await p.client.requestUpdate(id, { forward: 0 });
+ await settle();
+ release();
+ expect(await opening).toBe('rejected');
+ await settle();
+ expect(p.server.session.getIncomingSubscription(id)).toBeUndefined();
+ expect(p.server.session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('keeps a fill retryable when transport stream allocation rejects', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ const create = p.b.createUnidirectionalStream.bind(p.b);
+ const failure = new Error('stream allocation failed');
+ p.b.createUnidirectionalStream = async () => { throw failure; };
+ for (let i = 0; i < 3; i++) {
+ await expect(p.server.openFillStream(id)).rejects.toBe(failure);
+ }
+ p.b.createUnidirectionalStream = create;
+ const stream = await p.server.openFillStream(id);
+ await p.server.sendFetchObject(stream, {
+ groupId: 5n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]),
+ });
+ await p.server.closeFetchStream(stream);
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/);
+ expect(p.server.session.state).toBe(SessionState.ESTABLISHED);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('does not restore a cancelled fill when its allocation later rejects', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ let reject!: (error: Error) => void;
+ p.b.createUnidirectionalStream = () => new Promise((_resolve, fail) => { reject = fail; });
+ const opening = p.server.openFillStream(id).then(() => 'opened', () => 'rejected');
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/);
+ await p.client.unsubscribe(id);
+ await settle();
+ reject(new Error('allocation failed after cancellation'));
+ expect(await opening).toBe('rejected');
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/);
+ expect(p.server.session.getIncomingSubscription(id)).toBeUndefined();
+ });
+
+ it.each([18, 21] as const)('releases a failed ordinary FETCH allocation in draft %s', async (draft) => {
+ const p = await pair(draft);
+ p.server.onFetch = (id) => { void p.server.acceptFetch(id, {
+ endOfTrack: 0, endLocation: { group: 5n, object: 0n },
+ }).catch((e: Error) => p.errors.push(e)); };
+ const id = await p.client.fetch(ns('live'), nm('video'), {
+ startGroup: 0n, startObject: 0n, endGroup: 5n, endObject: 1n,
+ });
+ await settle();
+ const create = p.b.createUnidirectionalStream.bind(p.b);
+ p.b.createUnidirectionalStream = async () => { throw new Error('allocation failed'); };
+ await expect(p.server.openFetchStream(id)).rejects.toThrow(/allocation failed/);
+ p.b.createUnidirectionalStream = create;
+ const stream = await p.server.openFetchStream(id);
+ await p.server.closeFetchStream(stream);
+ await expect(p.server.openFetchStream(id)).rejects.toThrow(/No admitted inbound FETCH|one response stream/);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('serves a REQUEST_UPDATE fill on a publisher-initiated subscription', async () => {
+ const p = await pair();
+ p.server.setLargestLocationProvider(() => ({ group: 5n, object: 0n }));
+ p.client.onPublish = (pub) => { void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); };
+ const sub = await p.server.publish(ns('live'), nm('video'), ALIAS);
+ await settle();
+ const delivered: bigint[] = [];
+ p.client.onObject = (_sid, o) => delivered.push(o.groupId);
+ const update = await p.client.requestUpdate(sub, { fill: { groupOrder: 'descending' } });
+ await settle();
+ const stream = await p.server.openFillStream(update);
+ for (const groupId of [5n, 4n]) {
+ await p.server.sendFetchObject(stream, { groupId, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) });
+ }
+ await p.server.closeFetchStream(stream);
+ await settle();
+ expect(delivered).toEqual([5n, 4n]);
+ expect(p.errors).toEqual([]);
+ });
+
+ it.each(['no objects', 'inherited Next Group'] as const)('does not open an empty fill: %s', async (shape) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ if (shape === 'no objects') p.server.onSubscribe = (id) => { void p.server.acceptSubscribe(id, ALIAS); };
+ const id = await p.client.subscribe(ns('live'), nm('video'), { subscriptionFilter: { type: 'NextGroupStart' }, fill: {} });
+ await settle();
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/);
+ expect(p.errors).toEqual([]);
+ });
+
+ it('an explicit unfiltered fill overrides Next Group, but cannot exceed Largest Object', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), {
+ subscriptionFilter: { type: 'NextGroupStart' },
+ fill: { filter: { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n } },
+ });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) };
+ await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 6n })).rejects.toThrow(/fill range/);
+ await p.server.sendFetchObject(stream, { ...fields, groupId: 5n });
+ await p.server.closeFetchStream(stream);
+ expect(p.errors).toEqual([]);
+ });
+ it('counts a fill and prevents PUBLISH_DONE while its stream is open', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ const responses: ControlMessage[] = [];
+ p.client.onMessage = (message) => responses.push(message);
+ await expect(p.server.publishDone(id, varint(2n), 'done')).rejects.toThrow(/open/);
+ await p.server.closeFetchStream(stream);
+ await p.server.publishDone(id, varint(2n), 'done');
+ await settle();
+ expect(responses).toContainEqual(expect.objectContaining({ type: 'PUBLISH_DONE', streamCount: 1n }));
+ expect(p.errors).toEqual([]);
+ });
+
+ it('does not write an End-of-Range marker outside the fill snapshot', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ await expect(p.server.sendFetchEndOfRange(stream, false, 6n, 0n)).rejects.toThrow(/fill range/);
+ await p.server.sendFetchEndOfRange(stream, false, 5n, 0n);
+ await p.server.closeFetchStream(stream);
+ await settle();
+ expect(p.errors).toEqual([]);
+ });
+
+ it.each(['rejects', 'hangs'] as const)('fails closed if a failed fill header reset %s', async (outcome) => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ p.b.createUnidirectionalStream = async () => ({ getWriter: () => ({
+ write: async () => { throw new Error('header failed'); },
+ abort: async () => {
+ if (outcome === 'hangs') return new Promise(() => {});
+ throw new Error('reset failed');
+ },
+ }) }) as unknown as WritableStream;
+ await expect(p.server.openFillStream(id)).rejects.toThrow(/header failed/);
+ expect(p.server.session.state).toBe(SessionState.CLOSED);
+ }, 3000);
+
+ it('counts a completed fill even after its writer is reclaimed', async () => {
+ const p = await pair();
+ serveSubscriptions(p);
+ const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} });
+ await settle();
+ const stream = await p.server.openFillStream(id);
+ await p.server.closeFetchStream(stream);
+ expect(p.server.session.getIncomingSubscription(id)!.streamCount).toBe(1n);
+ const state = p.server as unknown as {
+ servedFills: Map; inboundFetchGroupOrder: Map; fetchServeReserved: Set;
+ };
+ expect(state.servedFills.has(id)).toBe(false);
+ expect(state.inboundFetchGroupOrder.has(id)).toBe(false);
+ expect(state.fetchServeReserved.has(id)).toBe(false);
+ await expect(p.server.openFillStream(id)).rejects.toThrow();
+ await expect(p.server.openFetchStream(id)).rejects.toThrow();
+ });
+
+ it('waits for the response before interpreting an early fill with publisher-preferred order', async () => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ const objects: MoqtObject[] = [];
+ const errors: Error[] = [];
+ conn.onObject = (_stream, object) => objects.push(object);
+ conn.onError = (error) => errors.push(error);
+ await conn.connect(transport);
+ const requestId = await conn.subscribe(ns('live'), nm('video'), { fill: {} });
+ transport.pushIncomingUni(new Uint8Array([
+ 0x05, 0x00, 0x1c, 0x05, 0x00, 0x03, 0x01, 0xa5, 0x0c, 0x00, 0x00, 0x01, 0xa4,
+ ]));
+ await settle();
+ expect(objects).toEqual([]);
+ transport.bidi[0]!.push(codec.encode({
+ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS,
+ parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]),
+ trackProperties: new Map([[0x22n, [2n]]]),
+ }));
+ await settle();
+ expect(objects.map((object) => object.groupId)).toEqual([5n, 4n]);
+ expect(errors).toEqual([]);
+ });
+});
+
+describe('draft-21 inbound publication notifications', () => {
+ it('accepts PUBLISH_STATE_NOTIFY on an established inbound PUBLISH stream', async () => {
+ const conn = new MoqtConnection(21);
+ connections.push(conn);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() }));
+ const errors: Error[] = [];
+ conn.onError = (error) => errors.push(error);
+ conn.onPublish = (pub) => { void conn.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => errors.push(e)); };
+ await conn.connect(transport);
+ const request = transport.pushIncomingBidi();
+ request.push(codec.encode({ type: 'PUBLISH', requestId: 1n, trackAlias: ALIAS,
+ trackNamespace: ns('live'), trackName: nm('video'), parameters: new Map(), trackProperties: new Map(),
+ }));
+ await settle();
+ request.push(codec.encode({ type: 'PUBLISH_STATE_NOTIFY', parameters: new Map([[0x09n, [{ group: 8n, object: 2n }]]]) }));
+ await settle();
+ expect(conn.session.getIncomingSubscription(1n)?.largestLocation).toEqual({ groupId: 8n, objectId: 2n });
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+});
diff --git a/packages/webtransport/src/adapter-d21.test.ts b/packages/webtransport/src/adapter-d21.test.ts
new file mode 100644
index 00000000..6134bc33
--- /dev/null
+++ b/packages/webtransport/src/adapter-d21.test.ts
@@ -0,0 +1,428 @@
+/**
+ * MoqtConnection at draft 21: the draft-18 stream model (unified SETUP on a uni
+ * control pair, one bidi stream per request) with the draft-21 message changes.
+ */
+import { describe, it, expect } from 'vitest';
+import { MoqtConnection } from './adapter.js';
+import { TransportSim } from './testkit/stream-sim.js';
+import { createControlCodec } from '@openmoq/transport';
+
+const codec21 = createControlCodec(21);
+const setupBytes = (): Uint8Array => codec21.encode({ type: 'SETUP', setupOptions: new Map() });
+
+describe('MoqtConnection(21) negotiation', () => {
+ it('connects with the unified SETUP on a uni control stream', async () => {
+ const conn = new MoqtConnection(21);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(setupBytes());
+
+ await conn.connect(transport);
+
+ expect(conn.draftVersion).toBe(21);
+ expect(transport.uniOut[0]!.writtenBytes()[0]).toBe(0xaf);
+ });
+
+ it('a connection without an explicit draft adopts a negotiated moqt-21', async () => {
+ const conn = new MoqtConnection();
+ const transport = Object.assign(new TransportSim(), { protocol: 'moqt-21' });
+ transport.openIncomingUni().push(setupBytes());
+
+ await conn.connect(transport);
+
+ expect(conn.draftVersion).toBe(21);
+ });
+});
+
+// ─── fills (draft-21 §3.4) and PUBLISH_STATE_NOTIFY (§9.10) ────────────
+
+import { writeVi64, vi64EncodingLength, varint, SessionState } from '@openmoq/transport';
+import type { ControlMessage, MoqtObject, DataStreamHeader, SubscribeOk } from '@openmoq/transport';
+import { flush } from './testkit/stream-sim.js';
+
+const ns = (s: string) => [new TextEncoder().encode(s)];
+const nm = (s: string) => new TextEncoder().encode(s);
+const okBytes = (alias: bigint): Uint8Array =>
+ codec21.encode({ type: 'SUBSCRIBE_OK', requestId: 0n, trackAlias: alias, parameters: new Map(), trackExtensions: new Map() } as SubscribeOk);
+const vi = (...values: bigint[]): Uint8Array => {
+ const buf = new Uint8Array(values.reduce((n, v) => n + vi64EncodingLength(v), 0));
+ let p = 0;
+ for (const v of values) p += writeVi64(v, buf, p);
+ return buf;
+};
+const concat = (...as: Uint8Array[]): Uint8Array => {
+ const out = new Uint8Array(as.reduce((n, a) => n + a.length, 0));
+ let p = 0;
+ for (const a of as) { out.set(a, p); p += a.length; }
+ return out;
+};
+const fetchHeader = (requestId: bigint) => vi(0x05n, requestId);
+// First fetch object: flags GROUP|OBJECT|PRIORITY (0x1C), group, object, priority, payload.
+const firstFetchObj = (group: bigint, object: bigint, payload: number[]) =>
+ concat(vi(0x1cn, group, object), new Uint8Array([3]), vi(BigInt(payload.length)), new Uint8Array(payload));
+
+async function connected21(): Promise<{ conn: MoqtConnection; transport: TransportSim }> {
+ const conn = new MoqtConnection(21);
+ const transport = new TransportSim();
+ transport.openIncomingUni().push(setupBytes());
+ await conn.connect(transport);
+ return { conn, transport };
+}
+
+async function subscribedWithFill(conn: MoqtConnection, transport: TransportSim) {
+ const p = conn.subscribeTrack(ns('live'), nm('catalog'), {
+ filter: { type: 'LargestObject' },
+ fill: { filter: { type: 'RelativeStart', groups: 1n } },
+ });
+ await flush();
+ transport.bidi[0]!.push(codec21.encode({ type: 'SUBSCRIBE_OK', requestId: 0n, trackAlias: 7n,
+ parameters: new Map([[0x09n, [{ group: 4n, object: 0n }]]]), trackProperties: new Map(),
+ })); // a non-empty track has a Largest Object and keeps its request stream open
+ return p;
+}
+
+describe('MoqtConnection(21) fill fetch streams', () => {
+ it('routes the fill stream by the SUBSCRIBE Request ID and leaves the subscription open', async () => {
+ const { conn, transport } = await connected21();
+ const objects: MoqtObject[] = [];
+ const streams: DataStreamHeader[] = [];
+ const closed: string[] = [];
+ conn.onObject = (_sid, o) => objects.push(o);
+ conn.onDataStream = (_sid, h) => streams.push(h);
+ conn.onStreamClosed = (_sid, _code, terminal) => closed.push(String(terminal));
+ const sub = await subscribedWithFill(conn, transport);
+
+ transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa])));
+ await flush();
+
+ expect(streams).toEqual([expect.objectContaining({ type: 'fetch', fill: true })]);
+ expect(objects.map((o) => [o.groupId, o.objectId])).toEqual([[4n, 0n]]);
+ expect(closed).toEqual(['fin']);
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ expect(transport.bidi[0]!.writeClosed).toBe(false); // the SUBSCRIBE stream is not finished
+ });
+
+ it('accepts one fill stream per request', async () => {
+ const { conn, transport } = await connected21();
+ const sub = await subscribedWithFill(conn, transport);
+ transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa])));
+ await flush();
+
+ transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa])));
+ await flush();
+
+ expect(conn.session.state).toBe(SessionState.CLOSED);
+ });
+
+ it('a FETCH_HEADER for a subscription that asked for no fill is still a protocol violation', async () => {
+ const { conn, transport } = await connected21();
+ const p = conn.subscribeTrack(ns('live'), nm('video'));
+ await flush();
+ transport.bidi[0]!.push(okBytes(8n));
+ const sub = await p;
+
+ transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(1n, 0n, [0x01])));
+ await flush();
+
+ expect(conn.session.state).toBe(SessionState.CLOSED);
+ });
+
+ it('cancelFill discards a fill stream that arrives later, keeping the session and subscription', async () => {
+ const { conn, transport } = await connected21();
+ const objects: MoqtObject[] = [];
+ conn.onObject = (_sid, o) => objects.push(o);
+ const sub = await subscribedWithFill(conn, transport);
+
+ await conn.cancelFill(sub.requestId);
+ transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa])));
+ await flush();
+
+ expect(objects).toEqual([]);
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ expect(transport.bidi[0]!.writeClosed).toBe(false);
+ });
+});
+
+describe('MoqtConnection(21) PUBLISH_STATE_NOTIFY', () => {
+ it('is surfaced on the subscription stream and advances its Largest Location', async () => {
+ const { conn, transport } = await connected21();
+ const seen: ControlMessage[] = [];
+ conn.onMessage = (m) => seen.push(m);
+ const p = conn.subscribeTrack(ns('live'), nm('video'));
+ await flush();
+ transport.bidi[0]!.push(okBytes(9n));
+ const sub = await p;
+
+ transport.bidi[0]!.push(codec21.encode({
+ type: 'PUBLISH_STATE_NOTIFY',
+ parameters: new Map([[0x09n, [{ group: 3n, object: 7n }]], [0x10n, [varint(0n)]]]),
+ } as ControlMessage));
+ await flush();
+
+ expect(seen).toContainEqual(expect.objectContaining({ type: 'PUBLISH_STATE_NOTIFY', requestId: sub.requestId }));
+ expect(conn.session.getSubscription(sub.requestId)!.largestLocation).toEqual({ groupId: 3n, objectId: 7n });
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('before SUBSCRIBE_OK it is a protocol violation', async () => {
+ const { conn, transport } = await connected21();
+ void conn.subscribeTrack(ns('live'), nm('video')).catch(() => undefined);
+ await flush();
+
+ transport.bidi[0]!.push(codec21.encode({ type: 'PUBLISH_STATE_NOTIFY', parameters: new Map() } as ControlMessage));
+ await flush();
+
+ expect(conn.session.state).toBe(SessionState.CLOSED);
+ });
+});
+
+describe('MoqtConnection(21) serving a fill (publisher side)', () => {
+ it('openFillStream answers a SUBSCRIBE fill on one fetch stream and leaves the subscription up', async () => {
+ const { connectedPair } = await import('./testkit/pair.js');
+ const { client, server, errors } = await connectedPair(21);
+ let fillRefusedWithoutRequest = false;
+ let alias = 12n;
+ server.onSubscribe = (requestId, _ns, trackName) => {
+ void (async () => {
+ await server.acceptSubscribe(requestId, alias++, { parameters: new Map([[0x09n, [{ group: 4n, object: 1n }]]]) });
+ if (new TextDecoder().decode(trackName) !== 'catalog') {
+ await server.openFillStream(requestId).catch(() => { fillRefusedWithoutRequest = true; });
+ return;
+ }
+ const sid = await server.openFillStream(requestId);
+ await server.sendFetchObject(sid, { groupId: 4n, subgroupId: 0n, objectId: 0n, publisherPriority: 3, payload: new Uint8Array([1]) });
+ await server.sendFetchObject(sid, { groupId: 4n, subgroupId: 0n, objectId: 1n, publisherPriority: 3, payload: new Uint8Array([2]) });
+ await server.closeFetchStream(sid);
+ })();
+ };
+ const objects: MoqtObject[] = [];
+ const streams: DataStreamHeader[] = [];
+ client.onObject = (_sid, o) => objects.push(o);
+ client.onDataStream = (_sid, h) => streams.push(h);
+
+ const sub = await client.subscribeTrack(ns('live'), nm('catalog'), {
+ filter: { type: 'LargestObject' },
+ fill: { filter: { type: 'RelativeStart', groups: 1n } },
+ });
+ await client.subscribeTrack(ns('live'), nm('video'));
+ for (let i = 0; i < 10; i++) await flush();
+
+ expect(streams).toEqual([expect.objectContaining({ fill: true })]);
+ expect(objects.map((o) => o.objectId)).toEqual([0n, 1n]);
+ expect(fillRefusedWithoutRequest).toBe(true);
+ expect(client.session.getSubscription(sub.requestId)).toBeDefined();
+ expect(server.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+});
+
+describe('MoqtConnection(21) fills on REQUEST_UPDATE (§3.4)', () => {
+ it('routes the fill stream carrying the REQUEST_UPDATE Request ID to the subscription', async () => {
+ const { client, server, errors } = await connectedPair21();
+ server.setLargestLocationProvider(() => ({ group: 1n, object: 0n }));
+ let subReq = -1n;
+ server.onSubscribe = (requestId) => { subReq = requestId; void server.acceptSubscribe(requestId, 30n); };
+ const streams: DataStreamHeader[] = [];
+ const objects: MoqtObject[] = [];
+ client.onDataStream = (_sid, h) => streams.push(h);
+ client.onObject = (_sid, o) => objects.push(o);
+ const sub = await client.subscribeTrack(ns('live'), nm('video'), { filter: { type: 'LargestObject' } });
+
+ const updateId = await client.requestUpdate(sub.requestId, {
+ fill: { filter: { type: 'AbsoluteRange', startGroup: 1n, startObject: 0n, endGroup: 1n } },
+ });
+ await flushN();
+ const sid = await server.openFillStream(updateId);
+ await server.sendFetchObject(sid, { groupId: 1n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([9]) });
+ await server.closeFetchStream(sid);
+ await flushN();
+
+ expect(subReq).toBe(sub.requestId);
+ expect(streams).toEqual([expect.objectContaining({ fill: true, header: expect.objectContaining({ requestId: updateId }) })]);
+ expect(objects.map((o) => [o.groupId, o.objectId])).toEqual([[1n, 0n]]);
+ expect(client.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+
+ it('cancelFill on the subscription also drops a fill its update asked for', async () => {
+ const { client, server } = await connectedPair21();
+ server.setLargestLocationProvider(() => ({ group: 0n, object: 0n }));
+ server.onSubscribe = (requestId) => { void server.acceptSubscribe(requestId, 31n); };
+ const objects: MoqtObject[] = [];
+ client.onObject = (_sid, o) => objects.push(o);
+ const sub = await client.subscribeTrack(ns('live'), nm('video'));
+ const updateId = await client.requestUpdate(sub.requestId, { fill: {} });
+ await client.cancelFill(sub.requestId);
+
+ const sid = await server.openFillStream(updateId);
+ await server.sendFetchObject(sid, { groupId: 0n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) });
+ await server.closeFetchStream(sid);
+ await flushN();
+
+ expect(objects).toEqual([]);
+ expect(client.session.state).toBe(SessionState.ESTABLISHED);
+ });
+});
+
+describe('MoqtConnection(21) MAX_REQUEST_UPDATES (§9.1.7)', () => {
+ it('the client keeps within the limit the server advertised', async () => {
+ const { client, server } = await connectedPair21({ serverSetup: { maxRequestUpdates: 1n } });
+ server.onSubscribe = () => { /* leave it pending: updates stay unanswered */ };
+ const pending = client.subscribeTrack(ns('live'), nm('video'));
+ pending.catch(() => undefined);
+ await flushN();
+ const subId = [...(client.session as unknown as { subscriptions: Map }).subscriptions.keys()][0]!;
+ await client.requestUpdate(subId, { forward: 0 });
+ await expect(client.requestUpdate(subId, { forward: 1 })).rejects.toThrow(/MAX_REQUEST_UPDATES/);
+ });
+
+ it('a peer exceeding our limit closes the session with TOO_MANY_REQUEST_UPDATES', async () => {
+ const { client, server } = await connectedPair21({ serverSetup: { maxRequestUpdates: 1n } });
+ server.onSubscribe = () => { /* pending: updates are queued, not answered */ };
+ let closedWith: bigint | undefined;
+ server.onClose = (code) => { closedWith = BigInt(code as never); };
+ // A client that ignores the advertised limit.
+ (client.session as unknown as { _peerMaxRequestUpdates: bigint })._peerMaxRequestUpdates = 0n;
+ const pending = client.subscribeTrack(ns('live'), nm('video'));
+ pending.catch(() => undefined);
+ await flushN();
+ const subId = [...(client.session as unknown as { subscriptions: Map }).subscriptions.keys()][0]!;
+ await client.requestUpdate(subId, { forward: 0 });
+ await flushN();
+ expect(server.session.state).toBe(SessionState.ESTABLISHED); // one outstanding is allowed
+ await client.requestUpdate(subId, { forward: 1 }).catch(() => undefined);
+ await flushN();
+ expect(server.session.state).toBe(SessionState.CLOSED);
+ expect(closedWith).toBe(0x1bn);
+ });
+});
+
+async function connectedPair21(opts: Parameters[1] = {}) {
+ const { connectedPair } = await import('./testkit/pair.js');
+ return connectedPair(21, opts);
+}
+
+async function flushN(n = 10): Promise {
+ for (let i = 0; i < n; i++) await flush();
+}
+
+
+describe('MoqtConnection native QUIC at draft 21', () => {
+ const quicTransport = (protocol: string) => Object.assign(new TransportSim(), {
+ kind: 'quic' as const,
+ protocol,
+ maxDatagramSize: 1200,
+ setupOptions: { path: '/moq', authority: 'relay.example:443' },
+ });
+
+ it('a moqt-21 transport selects draft 21', async () => {
+ const conn = new MoqtConnection();
+ const transport = quicTransport('moqt-21');
+ transport.openIncomingUni().push(setupBytes());
+ await conn.connect(transport);
+ expect(conn.draftVersion).toBe(21);
+ expect(conn.session.state).toBe(SessionState.ESTABLISHED);
+ });
+
+ it('a connection made for draft 21 refuses a moqt-18 transport', async () => {
+ const conn = new MoqtConnection(21);
+ const transport = quicTransport('moqt-18');
+ await expect(conn.connect(transport)).rejects.toThrow(/draft 18.*draft 21/);
+ await conn.close();
+ });
+});
+
+describe('MoqtConnection(21) subscriptions sharing a Track Alias (§3.1)', () => {
+ const SHARED = 50n;
+
+ async function sharedPair() {
+ const pair = await connectedPair21();
+ pair.server.onSubscribe = (requestId) => { void pair.server.acceptSubscribe(requestId, SHARED); };
+ return pair;
+ }
+
+ async function publish(server: MoqtConnection, group: bigint, object: bigint, requestId?: bigint): Promise {
+ const sid = await server.openSubgroup(varint(SHARED), varint(group), varint(0n), {
+ endOfGroup: false, publisherPriority: 1, ...(requestId === undefined ? {} : { requestId }),
+ });
+ await server.sendObject(sid, varint(object), new Uint8Array([Number(group), Number(object)]));
+ await server.closeSubgroup(sid);
+ await flushN();
+ }
+
+ const locations = (objs: MoqtObject[]) => objs.map((o) => `${o.groupId}/${o.objectId}`);
+
+ it('both subscriptions stay bound and each gets only the objects its filter selects', async () => {
+ const { client, server, errors } = await sharedPair();
+ const a: MoqtObject[] = [];
+ const b: MoqtObject[] = [];
+ const subA = await client.subscribeTrack(ns('live'), nm('video'), {
+ filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n },
+ onObject: (o) => a.push(o),
+ });
+ const subB = await client.subscribeTrack(ns('live'), nm('video'), {
+ filter: { type: 'AbsoluteStart', startGroup: 2n, startObject: 0n },
+ onObject: (o) => b.push(o),
+ });
+ expect(subA.trackAlias).toBe(SHARED);
+ expect(subB.trackAlias).toBe(SHARED);
+
+ await publish(server, 1n, 0n, subA.requestId);
+ await publish(server, 2n, 0n, subB.requestId);
+
+ expect(locations(a)).toEqual(['1/0']);
+ expect(locations(b)).toEqual(['2/0']);
+ expect(client.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+
+ it('an object sent once per matching subscription reaches each of them once', async () => {
+ const { client, server } = await sharedPair();
+ const a: MoqtObject[] = [];
+ const b: MoqtObject[] = [];
+ const subA = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) });
+ const subB = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) });
+
+ await publish(server, 3n, 0n, subA.requestId);
+ await publish(server, 3n, 0n, subB.requestId);
+
+ expect(locations(a)).toEqual(['3/0']);
+ expect(locations(b)).toEqual(['3/0']);
+ });
+
+ it('a PUBLISH_DONE for one subscription leaves the other receiving on the alias', async () => {
+ const pair = await connectedPair21();
+ const ids: bigint[] = [];
+ pair.server.onSubscribe = (requestId) => { ids.push(requestId); void pair.server.acceptSubscribe(requestId, SHARED); };
+ const { client, server, errors } = pair;
+ const a: MoqtObject[] = [];
+ const b: MoqtObject[] = [];
+ await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) });
+ await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) });
+
+ await server.publishDone(ids[0]!, varint(0x2n), 'track ended for the first');
+ await flushN();
+ await publish(server, 5n, 0n);
+
+ expect(a).toEqual([]);
+ expect(locations(b)).toEqual(['5/0']);
+ expect(client.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+
+ it('ending one subscription leaves the other receiving on the alias', async () => {
+ const { client, server, errors } = await sharedPair();
+ const a: MoqtObject[] = [];
+ const b: MoqtObject[] = [];
+ const subA = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) });
+ await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) });
+
+ await subA.unsubscribe();
+ await flushN();
+ await publish(server, 4n, 0n);
+
+ expect(a).toEqual([]);
+ expect(locations(b)).toEqual(['4/0']);
+ expect(client.session.state).toBe(SessionState.ESTABLISHED);
+ expect(errors).toEqual([]);
+ });
+});
diff --git a/packages/webtransport/src/adapter.ts b/packages/webtransport/src/adapter.ts
index 17130b57..fcad14e3 100644
--- a/packages/webtransport/src/adapter.ts
+++ b/packages/webtransport/src/adapter.ts
@@ -35,7 +35,12 @@ import {
RequestError18,
SubscriptionState,
ForwardState,
+ windowContains,
+ decodeFillParameters,
+ decodeSubscriptionFilter,
+ resolveFillWindow,
} from '@openmoq/transport';
+import type { FillOptions, SubscriptionWindow } from '@openmoq/transport';
import type {
EndpointRoleValue,
ControlMessage,
@@ -82,6 +87,7 @@ import { IncomingUniRouter, type RoutedIncomingUniStream } from './topology/inco
import { InboundRequestStreamContext } from './topology/inbound-request.js';
import { MoqtConnectionError } from './adapter-error.js';
import type { WebTransportLike, WebTransportBidirectionalStream, MoqtSetupRouting } from './types.js';
+import { isDraft21, isRequestStreamDraft, isWiredDraft } from '@openmoq/transport';
@@ -127,6 +133,13 @@ export interface TrackSubscribeOptions {
* still legitimately in flight (§10.11).
*/
readonly deliveryTimeout?: SubscribeOptions['deliveryTimeout'];
+ /**
+ * draft-21 §3.4 FILL_PARAMETERS: the publisher also delivers the fill range on
+ * a fill fetch stream. Its objects reach the connection's `onObject` like FETCH
+ * objects; `onDataStream` announces the stream with `fill: true` and this
+ * subscription's Request ID in the FETCH_HEADER.
+ */
+ readonly fill?: SubscribeOptions['fill'];
/** Called for each object delivered on this subscription (stream-based only; datagrams excluded). */
onObject?: (obj: MoqtObject) => void;
/**
@@ -163,6 +176,16 @@ interface RawSubState {
readonly sub: TrackSubscription;
resolve: ((sub: TrackSubscription) => void) | null;
reject: ((err: Error) => void) | null;
+ /** Objects already delivered while the alias is shared, for deduplication (bounded). */
+ seen?: Set;
+}
+
+/** Bound on the per-subscription dedup set of a shared Track Alias. */
+const SHARED_ALIAS_SEEN_LIMIT = 4096;
+
+/** Whether any Location of `group` falls inside `window`. */
+function windowCoversGroup(window: SubscriptionWindow, group: bigint): boolean {
+ return group >= window.start.group && (window.end === undefined || group <= window.end.group);
}
type PendingAliasEvent = {
@@ -221,7 +244,7 @@ export interface IncomingPublish {
* than trying to construct a codec for an unwired draft.
*
* @see draft-ietf-moq-transport-16 §3.1
- * - 'moqt-18' → 18, 'moqt-16' → 16
+ * - 'moqt-21' → 21, 'moqt-18' → 18, 'moqt-16' → 16
* - 'moq-00' → 14 (pre-15 convention)
* - '' / undefined / unsupported token → undefined (use constructor version)
*/
@@ -231,7 +254,7 @@ function protocolToDraftVersion(protocol: string | undefined): DraftVersion | un
const match = protocol.match(/^moqt-(\d+)$/);
if (!match) return undefined;
const n = parseInt(match[1]!, 10);
- return n === 14 || n === 16 || n === 18 ? (n as DraftVersion) : undefined;
+ return isWiredDraft(n) ? n : undefined;
}
/**
@@ -304,6 +327,9 @@ function splitOwnership(
return { sessionOptions: rest as T, onRequestId };
}
+/** draft-21 FILL_PARAMETERS message parameter (§9.20.16). */
+const FILL_PARAMETERS = 0x23n;
+
export class MoqtConnection {
/** The underlying sans-I/O session state machine. Set by {@link configureForVersion}. */
session!: Session;
@@ -401,6 +427,12 @@ export class MoqtConnection {
private rawSubscriptions = new Map();
/** Track subscriptions by trackAlias (active, alias resolved). */
private rawAliasMaps = new Map();
+ /**
+ * draft-21 §3.1: every subscription sharing a Track Alias (two or more, same
+ * track), in binding order. `rawAliasMaps` keeps the first as the alias owner
+ * for the terminal machinery; objects go to each member whose filter matches.
+ */
+ private readonly sharedAliasSubs = new Map();
/** Generic subscribe() requests awaiting SUBSCRIBE_OK / REQUEST_ERROR. */
private pendingGenericSubscriptions = new Set();
/** Local monotonic ownership generation for every unanswered SUBSCRIBE. */
@@ -434,7 +466,8 @@ export class MoqtConnection {
/** Inbound PUBLISH (draft-18 §10.10) stream contexts, keyed by Request ID. */
private inboundRequestContexts = new Map();
/** Inbound PUBLISH tracks by Track Alias, for routing data objects. */
- private publishAliasMaps = new Map();
+ private publishAliasMaps = new Map();
+ private subscriberObjectHistory = new WeakMap>();
/** Requested Group Order per inbound FETCH Request ID (default ascending). */
private inboundFetchGroupOrder = new Map();
@@ -469,7 +502,20 @@ export class MoqtConnection {
groupOrder: GroupOrder;
prior: FetchObjectPrior18 | undefined;
isFirstObject: boolean;
+ closing?: Promise;
+ /** A draft-21 fill stream: the Request ID of the subscription it fills. */
+ fillOf?: bigint;
+ fillWindow?: SubscriptionWindow;
}>();
+ private static readonly MAX_PENDING_FILLS = 256;
+ /** Inbound draft-21 fills awaiting an application response stream. */
+ private readonly inboundFillRequests = new Map();
+ /** Fills with a reserved or open response stream, mapped to their subscription. */
+ private readonly servedFills = new Map();
/**
* Called for each application-relevant control message received after setup.
@@ -648,6 +694,9 @@ export class MoqtConnection {
// Receiver terminal tracker (tombstone TTL timers) + seen counts.
for (const alias of [...this.terminatedAliases.keys()]) this.clearTerminatedAlias(alias);
this.aliasStreamsSeen.clear();
+ this.aliasTerminalTotals.clear();
+ for (const retained of this.terminalSubscribers.values()) retained.cancelTimer();
+ this.terminalSubscribers.clear();
this.aliasDeliveryTimeoutMs.clear();
this.aliasPublisherTimeout.clear();
this.inboundPublishTimeouts.clear();
@@ -680,7 +729,12 @@ export class MoqtConnection {
this.fetchGroupOrder.clear();
this.inboundFetchGroupOrder.clear();
this.fetchServeReserved.clear();
+ this.inboundFillRequests.clear();
+ this.servedFills.clear();
this.fetchStreams.clear();
+ this.fillStreams.clear();
+ for (const pending of this.pendingFillHeaders.values()) pending.finish(false);
+ this.pendingFillHeaders.clear();
this.recentlyCancelledFetches.clear();
// Incoming data-stream readers — cancel each, then drop. This is a LOCAL
// teardown: route it through the ownership primitive so a read parked in
@@ -983,6 +1037,7 @@ export class MoqtConnection {
*/
private outgoingStreams = new Map;
+ groupId: bigint;
hasExtensions: boolean;
previousObjectId: bigint;
isFirstObject: boolean;
@@ -1007,7 +1062,7 @@ export class MoqtConnection {
* On termination the entry is RETIRED (moved to {@link retiredPublisherAliases}),
* so this map tracks only live subscriptions.
*/
- private publisherAliasRequests = new Map();
+ private publisherAliasRequests = new Map>();
/**
* BOUNDED LRU of recently-RETIRED publisher alias → request ID. A send on a
@@ -1094,6 +1149,11 @@ export class MoqtConnection {
* observed or `terminatedAliasTtlMs` expires — never retained indefinitely.
*/
private aliasStreamsSeen = new Map();
+ private readonly aliasTerminalTotals = new Map();
+ private readonly terminalSubscribers = new Map void;
+ }>();
/**
* Per-alias tombstone. `remaining` is how many late streams are still expected
* (bigint, counted down as they arrive); `null` means the count is UNKNOWN —
@@ -1162,6 +1222,14 @@ export class MoqtConnection {
private fetchServeReserved = new Set();
/** §9.15 sentinel: publisher could not set an exact Stream Count. */
private static readonly STREAM_COUNT_UNKNOWN = (1n << 62n) - 1n;
+ /**
+ * draft-21 §3.4 fill fetch streams, by the Request ID that asked for the fill
+ * (the SUBSCRIBE's, or a REQUEST_UPDATE's), with the subscription they fill.
+ * Kept apart from `fetchStreams`: a fill has no FETCH of its own, and ending
+ * it must not touch the subscription's request stream.
+ */
+ private fillStreams = new Map();
+ private readonly pendingFillHeaders = new Map void }>();
/** setTimeout's safe upper bound (2^31-1 ms); longer durations are chunked. */
private static readonly MAX_TIMER_MS = 2_147_483_647;
@@ -1229,10 +1297,10 @@ export class MoqtConnection {
// PATH/AUTHORITY are forbidden over WebTransport but carry native-QUIC
// routing information. The sans-I/O gate enforces the selected binding.
this.session = new Session(this._role, version, { webtransport: transportKind === 'webtransport' });
- if (version === 18) {
- this.codec = createControlCodec(18);
+ if (isRequestStreamDraft(version)) {
+ this.codec = createControlCodec(version);
this.framer = new ControlStreamFramer(this.codec);
- this.dataCodec = createDataCodec(18);
+ this.dataCodec = createDataCodec(version);
this.uniPair = createUniPairTopology(this.session);
// Surface request-stream failures (unsolicited/wrong-typed response) at once,
// de-duped against the response-consumer path (same Error instance).
@@ -1293,13 +1361,14 @@ export class MoqtConnection {
// Native QUIC support starts at draft 18. Unlike the historical
// WebTransport fallback, ALPN is mandatory and must identify the exact
// wire draft before any MOQT stream is opened.
- if (transport.protocol !== 'moqt-18') {
+ const nativeDraft = transport.protocol === 'moqt-18' ? 18 : transport.protocol === 'moqt-21' ? 21 : undefined;
+ if (nativeDraft === undefined) {
throw new ProtocolViolationError(
- `native QUIC negotiated ALPN ${JSON.stringify(transport.protocol ?? '')}; expected "moqt-18"`,
+ `native QUIC negotiated ALPN ${JSON.stringify(transport.protocol ?? '')}; expected "moqt-18" or "moqt-21"`,
);
}
- if (this._requestedVersion !== undefined && this._requestedVersion !== 18) {
- throw new Error(`native QUIC supports draft 18 only, not draft ${this._requestedVersion}`);
+ if (this._requestedVersion !== undefined && this._requestedVersion !== nativeDraft) {
+ throw new Error(`native QUIC negotiated draft ${nativeDraft}, but this connection was created for draft ${this._requestedVersion}`);
}
if (!Number.isSafeInteger(transport.maxDatagramSize) || transport.maxDatagramSize! <= 0) {
throw new Error('native QUIC requires negotiated QUIC datagram support');
@@ -1314,7 +1383,7 @@ export class MoqtConnection {
throw new Error('native QUIC SETUP routing conflicts with the transport URI');
}
nativeRouting = routing;
- selectedVersion = 18;
+ selectedVersion = nativeDraft;
} else if (transportKind !== 'webtransport') {
throw new Error(`unsupported transport kind: ${String(transportKind)}`);
} else if (this._requestedVersion === undefined && transport.protocol) {
@@ -1331,7 +1400,7 @@ export class MoqtConnection {
// terminal coordinator, ignores a stale transport after migration.
void this.watchTransportClosed(transport);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// draft-18: open the uni control-stream pair and exchange SETUP. Request
// responses arrive on their own bidi streams (see subscribe()), so no
// shared control read loop is started. SETUP, subgroup, FETCH, and
@@ -1518,7 +1587,7 @@ export class MoqtConnection {
await send();
} catch (err) {
this.session.rollbackRequest(requestId, { retainCancellationProvenance: true });
- if (this.session.draftVersion !== 18) {
+ if (!isRequestStreamDraft(this.session.draftVersion)) {
this.closeSessionFatal(
`control-stream write failed: ${err instanceof Error ? err.message : String(err)}`,
);
@@ -1555,7 +1624,7 @@ export class MoqtConnection {
message: send.message,
});
} catch (err) {
- this.publisherAliasRequests.delete(trackAlias);
+ this.removePublisherAliasRequest(trackAlias, requestId);
if (!this.session.rollbackUnsentPublish(requestId)) {
this.closeSessionFatal(
`could not preserve the request sequence after PUBLISH ${requestId} failed before emission`,
@@ -1567,7 +1636,7 @@ export class MoqtConnection {
try {
await this.controlWriter!.write(bytes);
} catch (err) {
- this.publisherAliasRequests.delete(trackAlias);
+ this.removePublisherAliasRequest(trackAlias, requestId);
this.session.rollbackRequest(requestId, { retainCancellationProvenance: true });
this.closeSessionFatal(
`control-stream write failed while publishing request ${requestId}: ${err instanceof Error ? err.message : String(err)}`,
@@ -1584,7 +1653,7 @@ export class MoqtConnection {
* may answer (e.g. a §3.2 reserved-namespace REQUEST_ERROR) synchronously.
*/
private async sendSubscribeActions(actions: SessionOutboundAction[]): Promise {
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const subscribeMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message;
const requestId = (subscribeMsg as { requestId: bigint }).requestId;
// The delivery IS the topology's wire-order barrier: the read loop awaits
@@ -1642,17 +1711,22 @@ export class MoqtConnection {
namespace: Uint8Array[],
name: Uint8Array,
trackAlias: bigint,
- options?: { parameters?: Parameters; trackProperties?: TrackProperties },
+ options?: {
+ parameters?: Parameters;
+ trackProperties?: TrackProperties;
+ /** draft-21: inherit Forward from this accepted SUBSCRIBE_TRACKS. */
+ subscribeTracksRequestId?: bigint;
+ },
): Promise {
const { requestId, actions } = this.session.publish(namespace, name, trackAlias, options);
// Associate the advertised alias with this publish for §10.11 Stream Count
// accounting and terminal enforcement on the data-plane APIs.
- this.publisherAliasRequests.set(trackAlias, requestId);
- if (this.session.draftVersion === 18) {
+ this.addPublisherAliasRequest(trackAlias, requestId);
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const publishMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message;
// Roll back the publisher-alias authority too (so openSubgroup can't publish
// on an alias the peer never learned) if the request-stream open fails.
- await this.openD18Request(requestId, publishMsg, () => this.publisherAliasRequests.delete(trackAlias));
+ await this.openD18Request(requestId, publishMsg, () => this.removePublisherAliasRequest(trackAlias, requestId));
return requestId;
}
await this.emitLegacyPublishOrRollback(requestId, trackAlias, actions);
@@ -1804,7 +1878,7 @@ export class MoqtConnection {
*/
private refreshAliasDeliveryTimeout(alias: bigint): void {
const reqId = this.rawAliasMaps.get(alias)?.requestId
- ?? [...this.publishAliasMaps].find(([a]) => a === alias)?.[1]?.requestId;
+ ?? this.publishAliasMaps.get(alias)?.[0]?.requestId;
if (reqId === undefined || reqId === null) return;
const sub = this.session.getSubscription(reqId) ?? this.session.getIncomingSubscription(reqId);
if (!sub) return;
@@ -1941,7 +2015,9 @@ export class MoqtConnection {
if (!suppress) this.onMessage?.(stamped);
const fwdTarget = this.outboundPublishForwardTarget(stamped as { type: string; requestId?: bigint });
const fwdBefore = fwdTarget === undefined ? undefined : this.getPublishForwardState(fwdTarget);
- await this.executeActions(this.session.handleControlMessage(message, { requestId }));
+ const actions = this.session.handleControlMessage(message, { requestId });
+ this.settleFillHeaders();
+ await this.executeActions(actions);
const fwdAfter = this.changedPublishForwardState(fwdTarget, fwdBefore);
if (fwdTarget !== undefined && fwdAfter !== undefined) {
this.notifyPublishForwardChange(fwdTarget, fwdAfter);
@@ -2019,6 +2095,7 @@ export class MoqtConnection {
this.beginPendingAliasReplay(alias, requestId, null, replay);
this.onMessage?.(stampedOk);
}
+ this.settleFillHeaders();
await this.executeActions(actions);
}
@@ -2047,7 +2124,7 @@ export class MoqtConnection {
// session.unsubscribe: draft-14/16 → UNSUBSCRIBE control action + alias
// unregister; draft-18 → no control action + alias unregister.
await this.executeActions(this.session.unsubscribe(requestId));
- if (this.session.draftVersion === 18) await this.uniPair!.cancelRequest(requestId);
+ if (isRequestStreamDraft(this.session.draftVersion)) await this.uniPair!.cancelRequest(requestId);
this.onError?.(err);
}
@@ -2060,6 +2137,10 @@ export class MoqtConnection {
*/
private aliasReuseSafe(alias: bigint): boolean {
if (this.drainingAliases.has(alias)) return false; // drain window in progress
+ const live = this.session.getSubscriberRequestsForAlias(alias);
+ for (const { sub } of this.terminalSubscribers.values()) {
+ if (sub.trackAlias === alias && !live.some((owner) => owner.trackKey === sub.trackKey)) return false;
+ }
const t = this.terminatedAliases.get(alias);
return !t || (t.strict && t.remaining === 0n);
}
@@ -2114,6 +2195,7 @@ export class MoqtConnection {
}
this.rawSubscriptions.clear();
this.rawAliasMaps.clear();
+ this.sharedAliasSubs.clear();
this.pendingGenericSubscriptions.clear();
this.pendingSubscriptionGenerations.clear();
this.clearAllPendingAliases();
@@ -2139,26 +2221,47 @@ export class MoqtConnection {
// streams are actively stopped, not merely unrouted. The topology FINs our
// send direction and drains to the publisher's FIN.
if (message.type === 'PUBLISH_DONE') {
+ // A subscription sharing its alias ends alone: the alias, its streams and
+ // their terminal accounting stay with the other subscriptions (draft-21 §3.1).
+ const doneRaw = this.rawSubscriptions.get(originalRequestId);
+ const subscription = this.session.getSubscription(originalRequestId);
+ const alias = subscription?.trackAlias;
+ const aliasStillShared = alias !== undefined && this.aliasHasOtherSubscriber(alias, originalRequestId);
+ if (doneRaw) this.detachSharedAlias(doneRaw);
// Capture the alias BEFORE the session reclaims the subscription.
- const doneAlias = this.session.getSubscription(originalRequestId)?.trackAlias;
- const streamCount = (message as { streamCount?: bigint }).streamCount ?? 0n;
+ const doneAlias = aliasStillShared ? undefined : alias;
+ const ownCount = (message as { streamCount?: bigint }).streamCount ?? 0n;
+ const streamCount = alias === undefined ? ownCount : this.recordSubscriberTerminal(alias, ownCount);
const stampedDone = { ...message, requestId: originalRequestId } as ControlMessage;
// Terminal drain: arm SYNCHRONOUSLY, BEFORE the application callback —
// no callback may observe a DONE'd subscription without the drain pending.
- const draining = this.drainConfigs.has(originalRequestId) && doneAlias !== undefined;
- if (draining) this.applyTerminalDrain(doneAlias!, BigInt(streamCount), originalRequestId);
+ const draining = this.drainConfigs.has(originalRequestId) && alias !== undefined;
+ if (draining && subscription) this.retainTerminalSubscriber(subscription);
+ const cancelFills = draining ? Promise.resolve() : this.cancelFill(originalRequestId);
+ const aliasDraining = draining || (doneAlias !== undefined && [...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === doneAlias));
+ if (aliasDraining && doneAlias !== undefined) this.applyTerminalDrain(doneAlias, BigInt(streamCount), originalRequestId);
// Non-drained terminal application starts BEFORE the callback, like the
// drain above: its arm runs before the first await, so a reentrant
// close() inside the callback CLEARS the terminal state instead of having
// its cleanup undone by an arm that follows it.
- const discard = !draining && doneAlias !== undefined
+ const discard = !aliasDraining && doneAlias !== undefined
? this.applyTerminalStreamCount(doneAlias, BigInt(streamCount), originalRequestId)
: Promise.resolve();
this.onMessage?.(stampedDone);
await this.executeActions(this.session.handleControlMessage(stampedDone));
+ await cancelFills;
await discard;
return;
}
+ // draft-21 §9.10: the publisher's report on OUR subscription's request stream.
+ // Stamp the subscription's request ID, let the session apply it (Largest
+ // Location; an out-of-scope parameter closes the session), surface to the app.
+ if (message.type === 'PUBLISH_STATE_NOTIFY') {
+ const stampedNotify = { ...message, requestId: originalRequestId } as ControlMessage;
+ this.onMessage?.(stampedNotify);
+ await this.executeActions(this.session.handleControlMessage(stampedNotify));
+ return;
+ }
const updateId = (message as { requestId: bigint }).requestId;
const stamped = { ...message, existingRequestId: originalRequestId } as ControlMessage;
this.onMessage?.(stamped);
@@ -2179,6 +2282,10 @@ export class MoqtConnection {
// may send PUBLISH_DONE, which must remain behind this update response on
// the request stream. Do not await yet: Forward=0 must still be reported
// promptly when the transport write is blocked.
+ if (isDraft21(this.session.draftVersion) && send?.message.type === 'REQUEST_OK') {
+ if (!this.rememberInboundFill(updateId, originalRequestId, (message as { parameters: Parameters }).parameters)) return;
+ this.finalizeInboundFill(updateId, send.message.parameters);
+ }
const responseWrite = send
? this.uniPair!.writeOnRequest(originalRequestId, send.message)
: Promise.resolve();
@@ -2238,17 +2345,24 @@ export class MoqtConnection {
// request STILL owns the alias routing: a delayed peer-close after the alias
// was legitimately reused by a new subscription must not re-tombstone it.
const raw = this.rawSubscriptions.get(requestId);
- const teardownAlias = raw && raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw
- ? raw.trackAlias : null;
+ // A subscription sharing its alias leaves it to the others (draft-21 §3.1).
+ if (raw) this.detachSharedAlias(raw);
+ const alias = isDraft21(this.session.draftVersion)
+ ? this.session.getSubscription(requestId)?.trackAlias ?? raw?.trackAlias
+ : raw?.trackAlias;
+ const retained = this.terminalSubscribers.has(requestId);
+ if (alias != null && this.session.getSubscription(requestId)) this.recordSubscriberTerminal(alias, null);
+ const teardownAlias = !retained && alias != null
+ && !this.deliverySubscribers21(alias).some((sub) => sub.requestId !== requestId)
+ && (!raw || this.rawAliasMaps.get(alias) === raw) ? alias : null;
if (teardownAlias !== null) this.armSubscriberAliasTeardown(teardownAlias);
this.cancelPendingAliasReplaysForRequest(requestId);
-
- await this.executeActions(this.session.handleOutboundRequestClosed(requestId));
+ const cancelledFills = !retained && (alias == null || !this.drainingAliases.has(alias)) ? this.cancelFill(requestId) : Promise.resolve();
+ const closeActions = this.session.handleOutboundRequestClosed(requestId);
+ await cancelledFills;
+ await this.executeActions(closeActions);
+ if (teardownAlias !== null) await this.discardOpenStreamsForAlias(teardownAlias);
if (raw) {
- if (teardownAlias !== null) {
- // Tombstone armed above; now early-discard any streams still open.
- await this.discardOpenStreamsForAlias(teardownAlias);
- }
this.removeRawSubscription(raw);
// Defensive: a still-pending subscribeTrack() (no SUBSCRIBE_OK yet) must not
// hang when its request stream closes before the response (peer FIN/reset, or
@@ -2321,6 +2435,9 @@ export class MoqtConnection {
if (options?.deliveryTimeout !== undefined) {
subscribeOpts.deliveryTimeout = options.deliveryTimeout;
}
+ if (options?.fill !== undefined) {
+ subscribeOpts.fill = options.fill;
+ }
// Build the SUBSCRIBE synchronously (allocates the Request ID) so the response
// resolver is registered BEFORE any bytes go out. Otherwise a zero-latency peer
// (e.g. the §3.2 reserved-namespace auto-reject) could answer with REQUEST_ERROR
@@ -2345,7 +2462,9 @@ export class MoqtConnection {
// Register the resolver/rejecter synchronously (executor runs now), THEN send.
const result = new Promise((resolve, reject) => {
- this.rawSubscriptions.set(reqIdBigint, { requestId: reqIdBigint, trackAlias: null, sub, resolve, reject });
+ this.rawSubscriptions.set(reqIdBigint, {
+ requestId: reqIdBigint, trackAlias: null, sub, resolve, reject,
+ });
});
try {
await this.sendSubscribeActions(actions);
@@ -2369,6 +2488,136 @@ export class MoqtConnection {
* Returns true if the object was claimed by a track subscription.
* Called from data stream handlers before this.onObject.
*/
+ /**
+ * Bind a subscription's route at SUBSCRIBE_OK. On draft 21 a publisher MAY give
+ * concurrent subscriptions to one track the same alias (§3.1); the session has
+ * already verified it is the same track, so the subscription joins the alias's
+ * group instead of replacing the route.
+ */
+ private bindRawAlias(alias: bigint, raw: RawSubState): void {
+ if (!isDraft21(this.session.draftVersion)) {
+ this.rawAliasMaps.set(alias, raw);
+ return;
+ }
+ const current = this.rawAliasMaps.get(alias);
+ if (current === undefined || current === raw) {
+ this.rawAliasMaps.set(alias, raw);
+ return;
+ }
+ const group = this.sharedAliasSubs.get(alias) ?? [current];
+ if (!group.includes(raw)) group.push(raw);
+ this.sharedAliasSubs.set(alias, group);
+ }
+
+ private bindPublishAlias(pub: IncomingPublish): void {
+ const members = isDraft21(this.session.draftVersion) ? this.publishAliasMaps.get(pub.trackAlias) ?? [] : [];
+ members.push(pub);
+ this.publishAliasMaps.set(pub.trackAlias, members);
+ }
+
+ private publishAliasForRequest(requestId: bigint): bigint | undefined {
+ for (const [alias, members] of this.publishAliasMaps) {
+ if (members.some((pub) => pub.requestId === requestId)) return alias;
+ }
+ return undefined;
+ }
+
+ private removePublishAliasMember(alias: bigint, requestId: bigint): void {
+ const members = this.publishAliasMaps.get(alias)?.filter((pub) => pub.requestId !== requestId) ?? [];
+ if (members.length > 0) this.publishAliasMaps.set(alias, members);
+ else this.publishAliasMaps.delete(alias);
+ }
+
+ private aliasHasOtherSubscriber(alias: bigint, requestId: bigint): boolean {
+ return isDraft21(this.session.draftVersion)
+ && this.session.getSubscriberRequestsForAlias(alias).some((sub) => sub.requestId !== requestId);
+ }
+
+ private deliverySubscribers21(alias: bigint): SubscriptionStateMachine[] {
+ return [
+ ...this.session.getSubscriberRequestsForAlias(alias),
+ ...[...this.terminalSubscribers.values()].filter(({ sub }) => sub.trackAlias === alias).map(({ sub }) => sub),
+ ];
+ }
+
+ private recordSubscriberTerminal(alias: bigint, count: bigint | null): bigint {
+ if (!isDraft21(this.session.draftVersion)) return count ?? MoqtConnection.STREAM_COUNT_UNKNOWN;
+ const unknown = 0xffffffffffffffffn;
+ const prior = this.aliasTerminalTotals.get(alias);
+ const total = prior === null || count === null || count === unknown ? null : (prior ?? 0n) + count;
+ this.aliasTerminalTotals.set(alias, total);
+ return total === null || total >= unknown ? unknown : total;
+ }
+
+ private retainTerminalSubscriber(sub: SubscriptionStateMachine): void {
+ if (!isDraft21(this.session.draftVersion) || this.terminalSubscribers.has(sub.requestId)) return;
+ const ttl = Math.max(this.terminatedAliasTtlMs, this.aliasDeliveryTimeoutMs.get(sub.trackAlias!) ?? 0);
+ const cancelTimer = this.armGuardTimer(ttl, () => { void this.finishTerminalSubscriber(sub.requestId); });
+ this.terminalSubscribers.set(sub.requestId, { sub, cancelTimer });
+ }
+
+ private async finishTerminalSubscriber(requestId: bigint): Promise {
+ const retained = this.terminalSubscribers.get(requestId);
+ if (!retained) return;
+ this.terminalSubscribers.delete(requestId);
+ retained.cancelTimer();
+ await this.cancelFill(requestId);
+ if (this._terminated) return;
+ const cfg = this.drainConfigs.get(requestId);
+ this.drainConfigs.delete(requestId);
+ try { cfg?.onDrained?.(requestId); }
+ catch (err) {
+ try { this.onError?.(err instanceof Error ? err : new Error(String(err))); } catch { /* observer failure */ }
+ }
+ }
+
+ private hasDeliveredObject(sub: SubscriptionStateMachine, obj: MoqtObject): boolean {
+ const key = `${obj.groupId}/${obj.subgroupId ?? ''}/${obj.objectId}`;
+ let seen = this.subscriberObjectHistory.get(sub);
+ if (seen?.has(key)) return true;
+ if (!seen) this.subscriberObjectHistory.set(sub, seen = new Set());
+ seen.add(key);
+ if (seen.size > SHARED_ALIAS_SEEN_LIMIT) seen.delete(seen.values().next().value!);
+ return false;
+ }
+
+ /**
+ * draft-21 §3.1: an object on a shared alias could belong to any of the
+ * subscriptions; each receives it when its filter matches, at most once (the
+ * publisher sends one copy per matching subscription).
+ */
+ private deliverToSharedAlias(group: readonly RawSubState[], obj: MoqtObject): void {
+ const groupId = BigInt(obj.groupId);
+ const objectId = BigInt(obj.objectId);
+ const key = `${groupId}/${String(obj.subgroupId ?? '')}/${objectId}`;
+ for (const raw of [...group]) {
+ if (this.rawSubscriptions.get(raw.requestId) !== raw) continue;
+ const window = this.session.getSubscription(raw.requestId)?.locationWindow;
+ if (window && !windowContains(window, groupId, objectId)) continue;
+ const seen = raw.seen ??= new Set();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ if (seen.size > SHARED_ALIAS_SEEN_LIMIT) seen.delete(seen.values().next().value!);
+ raw.sub.onObject?.(obj);
+ }
+ }
+
+ /**
+ * Take `raw` out of its alias group. True when other subscriptions still use
+ * the alias, so the caller must leave the alias route and its streams alone.
+ */
+ private detachSharedAlias(raw: RawSubState): boolean {
+ const alias = raw.trackAlias;
+ if (alias === null) return false;
+ const group = this.sharedAliasSubs.get(alias);
+ if (!group || !group.includes(raw)) return false;
+ const rest = group.filter((r) => r !== raw);
+ if (rest.length > 1) this.sharedAliasSubs.set(alias, rest);
+ else this.sharedAliasSubs.delete(alias);
+ if (this.rawAliasMaps.get(alias) === raw) this.rawAliasMaps.set(alias, rest[0]!);
+ return true;
+ }
+
private routeToTrackSubscription(streamId: bigint, obj: MoqtObject): boolean {
const alias = BigInt(obj.trackAlias);
const replay = this.pendingAliasReplays.get(alias);
@@ -2377,13 +2626,40 @@ export class MoqtConnection {
kind: 'object', streamId, object: obj, ownerGeneration: 0n,
});
}
+ if (isDraft21(this.session.draftVersion)) {
+ const members = this.deliverySubscribers21(alias);
+ if (members.length > 0) {
+ const generic: SubscriptionStateMachine[] = [];
+ for (const member of members) {
+ if (this._terminated || !this.deliverySubscribers21(alias).includes(member)) continue;
+ const window = member.locationWindow;
+ if (window && !windowContains(window, obj.groupId, obj.objectId)) continue;
+ if (this.hasDeliveredObject(member, obj)) continue;
+ const raw = this.rawSubscriptions.get(member.requestId);
+ if (raw) raw.sub.onObject?.(obj);
+ else if (!member.isPublishInitiated) generic.push(member);
+ else {
+ this.publishAliasMaps.get(alias)?.find((pub) => pub.requestId === member.requestId)?.onObject?.(obj);
+ }
+ }
+ if (!this._terminated && generic.some((sub) => this.deliverySubscribers21(alias).includes(sub))) this.onObject?.(streamId, obj);
+ return true;
+ }
+ if (this.drainingAliases.has(alias)) return true;
+ }
+ const shared = this.sharedAliasSubs.get(alias);
+ if (shared) {
+ this.deliverToSharedAlias(shared, obj);
+ return true;
+ }
const rawSub = this.rawAliasMaps.get(alias);
if (rawSub) {
- rawSub.sub.onObject?.(obj);
+ if (isDraft21(this.session.draftVersion)) this.deliverToSharedAlias([rawSub], obj);
+ else rawSub.sub.onObject?.(obj);
return true;
}
// draft-18 §10.10: objects for an accepted inbound PUBLISH route by alias.
- const pub = this.publishAliasMaps.get(alias);
+ const pub = this.publishAliasMaps.get(alias)?.[0];
if (pub) {
pub.onObject?.(obj);
return true;
@@ -2398,7 +2674,7 @@ export class MoqtConnection {
if (this.terminatedAliases.has(alias)) return true;
// An established generic subscribe() intentionally uses the connection-wide
// onObject callback. A pending raw subscription elsewhere must not steal it.
- if (this.session.getTrackByAlias(varint(alias)) !== undefined) return false;
+ if (this.session.getTrackByAlias(alias) !== undefined) return false;
// The publisher may deliver objects before its SUBSCRIBE_OK is processed
// (control and data streams are not ordered relative to each other, §10.4);
// dropping them would permanently starve the subscription of anything it
@@ -2428,17 +2704,42 @@ export class MoqtConnection {
});
return;
}
+ if (isDraft21(this.session.draftVersion)) {
+ const members = this.session.getSubscriberRequestsForAlias(alias);
+ if (members.length > 0) {
+ for (const member of members) {
+ if (this._terminated || !this.session.getSubscriberRequestsForAlias(alias).includes(member)) continue;
+ const window = member.locationWindow;
+ if (window && !windowCoversGroup(window, header.groupId)) continue;
+ const raw = this.rawSubscriptions.get(member.requestId);
+ if (raw) raw.sub.onSubgroupClosed?.(header);
+ else this.publishAliasMaps.get(alias)?.find((pub) => pub.requestId === member.requestId)?.onSubgroupClosed?.(header);
+ }
+ return;
+ }
+ }
+ const shared = this.sharedAliasSubs.get(alias);
+ if (shared) {
+ const group = BigInt(header.groupId);
+ for (const raw of shared) {
+ if (this.rawSubscriptions.get(raw.requestId) !== raw) continue;
+ const window = this.session.getSubscription(raw.requestId)?.locationWindow;
+ if (window && !windowCoversGroup(window, group)) continue;
+ raw.sub.onSubgroupClosed?.(header);
+ }
+ return;
+ }
const rawSub = this.rawAliasMaps.get(alias);
if (rawSub) {
rawSub.sub.onSubgroupClosed?.(header);
return;
}
- const pub = this.publishAliasMaps.get(alias);
- if (pub) {
- pub.onSubgroupClosed?.(header);
+ const publications = this.publishAliasMaps.get(alias);
+ if (publications) {
+ for (const pub of [...publications]) pub.onSubgroupClosed?.(header);
return;
}
- if (this.session.getTrackByAlias(varint(alias)) !== undefined) return;
+ if (this.session.getTrackByAlias(alias) !== undefined) return;
this.bufferPendingAlias(alias, undefined, header);
}
@@ -2501,7 +2802,7 @@ export class MoqtConnection {
if (!this.hasPendingRawSubscription()) return false;
if (this.terminatedAliases.has(alias)) return false;
if (this.publishAliasMaps.has(alias)) return false;
- if (this.session.getTrackByAlias(varint(alias)) !== undefined) return false;
+ if (this.session.getTrackByAlias(alias) !== undefined) return false;
const ownerGeneration = this.latestPendingSubscriptionGeneration();
if (ownerGeneration === undefined) return false;
@@ -2685,6 +2986,7 @@ export class MoqtConnection {
}
private removeRawSubscription(raw: RawSubState): void {
+ this.detachSharedAlias(raw);
if (this.rawSubscriptions.get(raw.requestId) === raw) this.rawSubscriptions.delete(raw.requestId);
if (raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw) {
this.rawAliasMaps.delete(raw.trackAlias);
@@ -2730,6 +3032,7 @@ export class MoqtConnection {
this.refreshAliasDeliveryTimeout(alias); // §8: reflect any accepted REQUEST_UPDATE before arming
this.armTerminatedAlias(alias, null, /* strict */ false); // TTL-only; refuses reuse until it expires
this.rawAliasMaps.delete(alias);
+ this.sharedAliasSubs.delete(alias);
}
/** Early-discard (STOP_SENDING) every incoming subgroup stream open on `alias`. */
@@ -2743,7 +3046,9 @@ export class MoqtConnection {
private clearTerminatedAlias(alias: bigint): void {
const t = this.terminatedAliases.get(alias);
if (t) { t.cancelTimer(); this.terminatedAliases.delete(alias); }
+ if (!t && isDraft21(this.session.draftVersion) && this.deliverySubscribers21(alias).length > 1) return;
this.aliasStreamsSeen.delete(alias);
+ this.aliasTerminalTotals.delete(alias);
this.aliasDeliveryTimeoutMs.delete(alias);
this.aliasPublisherTimeout.delete(alias);
}
@@ -2788,6 +3093,7 @@ export class MoqtConnection {
// where no request-stream close tears rawAliasMaps down separately).
this.publishAliasMaps.delete(alias);
this.rawAliasMaps.delete(alias);
+ this.sharedAliasSubs.delete(alias);
this.cancelPendingAliasReplay(alias);
const existing = this.terminatedAliases.get(alias);
// Do NOT downgrade a STRICT Stream-Count tombstone (which tracks outstanding
@@ -2822,14 +3128,14 @@ export class MoqtConnection {
*/
private applyTerminalDrain(alias: bigint, streamCount: bigint, owningRequestId: bigint): void {
const owner = this.rawAliasMaps.get(alias)?.requestId
- ?? this.publishAliasMaps.get(alias)?.requestId;
+ ?? this.publishAliasMaps.get(alias)?.[0]?.requestId;
if (owner !== undefined && owner !== owningRequestId) return; // crossed OLD DONE on a reused alias
if (this.drainingAliases.has(alias)) return; // already draining — exactly-once
// §8: reflect any accepted REQUEST_UPDATE in the effective timeout first.
this.refreshAliasDeliveryTimeout(alias);
- const UNKNOWN_COUNT = (1n << 62n) - 1n; // "could not give an exact count" sentinel
+ const UNKNOWN_COUNT = isDraft21(this.session.draftVersion) ? 0xffffffffffffffffn : MoqtConnection.STREAM_COUNT_UNKNOWN;
const seen = this.aliasStreamsSeen.get(alias) ?? 0n;
- const acceptRemaining = streamCount >= UNKNOWN_COUNT
+ const acceptRemaining = streamCount === UNKNOWN_COUNT
? null
: (streamCount > seen ? streamCount - seen : 0n);
const ttlMs = Math.max(this.terminatedAliasTtlMs, this.aliasDeliveryTimeoutMs.get(alias) ?? 0);
@@ -2847,6 +3153,7 @@ export class MoqtConnection {
// through the sweep's await window below.
state.drained = true;
state.cancelTimer();
+ await this.cancelFill(state.requestId);
// Cancel still-open streams and AWAIT their cleanup (fail-closed: a
// cancellation that never settles keeps the drain safely blocked).
await this.discardOpenStreamsForAlias(alias);
@@ -2855,6 +3162,11 @@ export class MoqtConnection {
// teardown race this guards.
if (this.drainingAliases.get(alias) !== state) return;
this.drainingAliases.delete(alias);
+ this.aliasStreamsSeen.delete(alias);
+ this.aliasTerminalTotals.delete(alias);
+ for (const [id, retained] of [...this.terminalSubscribers]) {
+ if (retained.sub.trackAlias === alias) await this.finishTerminalSubscriber(id);
+ }
// Deliberately NO tombstone: the drain window WAS the §10.11 retention.
// The alias is now free, and a same-alias stream arriving next may be the
// REUSED alias's first data (racing its SUBSCRIBE_OK) — it must reach the
@@ -2881,7 +3193,7 @@ export class MoqtConnection {
// new route (§11.1). `undefined` owner (no live route) is the terminating
// request's own already-partly-torn-down subscription — proceed.
const owner = this.rawAliasMaps.get(alias)?.requestId
- ?? this.publishAliasMaps.get(alias)?.requestId;
+ ?? this.publishAliasMaps.get(alias)?.[0]?.requestId;
if (owner !== undefined && owner !== owningRequestId) return;
// 1) Install terminal state synchronously (before any await).
@@ -2891,7 +3203,8 @@ export class MoqtConnection {
// the updated window are still valid.
this.refreshAliasDeliveryTimeout(alias);
const seen = this.aliasStreamsSeen.get(alias) ?? 0n;
- const remaining: bigint | null = streamCount === MoqtConnection.STREAM_COUNT_UNKNOWN
+ const unknown = isDraft21(this.session.draftVersion) ? 0xffffffffffffffffn : MoqtConnection.STREAM_COUNT_UNKNOWN;
+ const remaining: bigint | null = streamCount === unknown
? null // §9.15 sentinel: exact count unknown — rely on the TTL only
: (streamCount - seen > 0n ? streamCount - seen : 0n);
this.armTerminatedAlias(alias, remaining, /* strict */ true);
@@ -2923,7 +3236,7 @@ export class MoqtConnection {
// alias, just before this call. Here we only bind routing + resolve.
raw.trackAlias = alias;
(raw.sub as { trackAlias: bigint }).trackAlias = alias;
- this.rawAliasMaps.set(alias, raw);
+ this.bindRawAlias(alias, raw);
// Claim only events that named this exact request while it was pending.
// Callback delivery remains deferred until after raw.resolve() below.
// onObject/onSubgroupClosed are documented "mutable, read live on each delivery":
@@ -2993,7 +3306,7 @@ export class MoqtConnection {
existingRequestId: bigint,
options?: RequestUpdateOptions,
): Promise {
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// Local REQUEST_UPDATE is only valid for an inbound PUBLISH (we are the
// subscriber there). For an inbound SUBSCRIBE we are the publisher — the
// peer updates that subscription, not us — so it is NOT eligible here.
@@ -3018,7 +3331,7 @@ export class MoqtConnection {
// ALSO drop publishAliasMaps and arm the terminal-alias guard — otherwise
// the route stays live and a late/independent data stream on the alias
// would keep reaching the application.
- const pubAlias = [...this.publishAliasMaps].find(([, p]) => p.requestId === existingRequestId)?.[0];
+ const pubAlias = this.publishAliasForRequest(existingRequestId);
if (pubAlias !== undefined) {
await this.rollbackFailedAcceptance(existingRequestId, pubAlias, /* isPublishInitiated */ true);
} else {
@@ -3107,21 +3420,29 @@ export class MoqtConnection {
// direct API, the TrackSubscription wrapper, and the peer-close path all
// arm at the same point and cannot diverge.
const raw = this.rawSubscriptions.get(requestId);
- if (raw && raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw) {
- this.armSubscriberAliasTeardown(raw.trackAlias);
- }
-
- const actions = this.session.unsubscribe(requestId); // draft-18 returns no send_control
+ const alias = isDraft21(this.session.draftVersion)
+ ? this.session.getSubscription(requestId)?.trackAlias ?? raw?.trackAlias
+ : raw?.trackAlias;
+ // A subscription sharing its alias leaves it to the others (draft-21 §3.1).
+ const aliasStillShared = alias != null && isDraft21(this.session.draftVersion)
+ && this.deliverySubscribers21(alias).some((sub) => sub.requestId !== requestId);
+ if (alias != null && this.session.getSubscription(requestId)) this.recordSubscriberTerminal(alias, null);
+ if (raw) this.detachSharedAlias(raw);
+ if (!aliasStillShared && alias != null) this.armSubscriberAliasTeardown(alias);
+
+ // draft-21 §3.4: the fill ends with its subscription.
+ const cancelledFills = this.cancelFill(requestId);
+ const actions = this.session.unsubscribe(requestId); // Retire before any callback can resume.
+ await cancelledFills;
await this.executeActions(actions);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// Reset the subscribe request stream; this is the draft-18 cancellation
// signal (a LOCAL cancel — the response handler ignores the resulting
// RequestCancelledError, so it surfaces no onError).
await this.uniPair!.cancelRequest(requestId);
- const current = this.rawSubscriptions.get(requestId) ?? raw;
- if (current && current.trackAlias !== null) {
+ if (!aliasStillShared && alias != null) {
// Tombstone already armed above; now early-discard open streams.
- await this.discardOpenStreamsForAlias(current.trackAlias);
+ await this.discardOpenStreamsForAlias(alias);
}
}
// Drop the raw subscription for EVERY draft: draft-14/16 has no request-stream
@@ -3162,31 +3483,37 @@ export class MoqtConnection {
* timeout timer — those remain application/timer concerns. A server MAY supply a
* `newSessionUri`; a client MUST leave it empty. For draft-18, `requestId` (the
* smallest peer Request ID that may be unprocessed) is required by the receiver
- * and MUST match the peer's request-id parity.
+ * and MUST match the peer's request-id parity. Draft 21 omits this field.
* @see draft-ietf-moq-transport-18 §10.4
+ * @see draft-ietf-moq-transport-21 §9.2
*/
async sendGoaway(options: { newSessionUri?: string; timeout?: bigint; requestId?: bigint } = {}): Promise {
const newSessionUri = options.newSessionUri ?? '';
- if (this.session.draftVersion === 18) {
- // §10.4 local guards — never emit an invalid control-stream GOAWAY. The peer
- // would reject these; refuse to put them on the wire in the first place.
- if (options.requestId === undefined) {
- throw new MoqtConnectionError('sendGoaway (draft-18): a control-stream GOAWAY requires a Request ID', { errorSource: 'control' });
- }
+ if (isRequestStreamDraft(this.session.draftVersion)) {
if (this._role === EndpointRole.CLIENT && newSessionUri.length > 0) {
- throw new MoqtConnectionError('sendGoaway (draft-18): a client MUST send a zero-length New Session URI', { errorSource: 'control' });
+ throw new MoqtConnectionError(`sendGoaway (draft-${this.session.draftVersion}): a client MUST send a zero-length New Session URI`, { errorSource: 'control' });
}
- // The Request ID refers to the RECEIVER's request IDs, so it must carry the
- // peer's parity: server→client is even, client→server is odd.
- const expectedParity = this._role === EndpointRole.SERVER ? 0n : 1n;
- if ((options.requestId & 1n) !== expectedParity) {
- throw new MoqtConnectionError(
- `sendGoaway (draft-18): Request ID ${options.requestId} has the wrong parity for the peer (expected ${expectedParity === 0n ? 'even' : 'odd'})`,
- { errorSource: 'control' },
- );
+ let requestId: bigint | undefined;
+ if (!isDraft21(this.session.draftVersion)) {
+ if (options.requestId === undefined) {
+ throw new MoqtConnectionError('sendGoaway (draft-18): a control-stream GOAWAY requires a Request ID', { errorSource: 'control' });
+ }
+ // Draft 18 refers to the receiver's requests: server-to-client is even,
+ // client-to-server is odd. Draft 21 has no Request ID in GOAWAY.
+ const expectedParity = this._role === EndpointRole.SERVER ? 0n : 1n;
+ if ((options.requestId & 1n) !== expectedParity) {
+ throw new MoqtConnectionError(
+ `sendGoaway (draft-18): Request ID ${options.requestId} has the wrong parity for the peer (expected ${expectedParity === 0n ? 'even' : 'odd'})`,
+ { errorSource: 'control' },
+ );
+ }
+ requestId = options.requestId;
}
- const message: Goaway = { type: 'GOAWAY', newSessionUri, timeout: options.timeout ?? 0n, requestId: options.requestId };
+ const message: Goaway = {
+ type: 'GOAWAY', newSessionUri, timeout: options.timeout ?? 0n,
+ ...(requestId !== undefined ? { requestId } : {}),
+ };
await this.uniPair!.sendControl(message); // draft-18 control stream is the uni pair
return;
}
@@ -3220,7 +3547,7 @@ export class MoqtConnection {
namespacePrefix,
subscribeOptions,
);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// draft-18 §10.18: SUBSCRIBE_NAMESPACE opens a CONTINUING request stream.
// The first response (REQUEST_OK / REQUEST_ERROR) goes through the normal
// stamped pipeline; subsequent NAMESPACE / NAMESPACE_DONE are routed to
@@ -3341,7 +3668,7 @@ export class MoqtConnection {
return;
}
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// draft-18 §10.18: gracefully close the continuing request stream.
await this.uniPair!.closeContinuingRequest(requestId);
return;
@@ -3369,7 +3696,7 @@ export class MoqtConnection {
* @param requestId The request ID of the track subscription to cancel
*/
async cancelTracks(requestId: bigint): Promise {
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
await this.uniPair!.closeContinuingRequest(requestId);
}
}
@@ -3395,7 +3722,7 @@ export class MoqtConnection {
const { sessionOptions, onRequestId } = splitOwnership(options);
const { requestId, actions } = this.session.fetch(namespace, name, sessionOptions as FetchOptions);
this.invokeOwnershipCallback(requestId, onRequestId);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// draft-18: FETCH opens its own bidi request stream rather than travelling
// on the control stream. Same machinery as subscribe(). Remember the
// requested Group Order so the fetch-object decoder can read deltas.
@@ -3427,7 +3754,7 @@ export class MoqtConnection {
const { sessionOptions, onRequestId } = splitOwnership(options);
const { requestId, actions } = this.session.joiningFetch(sessionOptions as JoiningFetchOptions);
this.invokeOwnershipCallback(requestId, onRequestId);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
this.fetchGroupOrder.set(requestId, options.groupOrder ?? 'ascending');
const fetchMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message;
await this.openD18Request(requestId, fetchMsg, () => this.fetchGroupOrder.delete(requestId));
@@ -3462,22 +3789,60 @@ export class MoqtConnection {
}
/**
- * Cancel an active fetch.
+ * Stop a draft-21 fill (§3.4) without touching its subscription: STOP_SENDING on
+ * the fill fetch stream if it is open, or discard it when it arrives. The
+ * subscription keeps delivering live objects.
*
- * draft-14/16: sends FETCH_CANCEL on the control stream, and STOP_SENDING on
- * the associated data stream if open.
- * §9.18: "A subscriber sends a FETCH_CANCEL message to a publisher to
- * indicate it is no longer interested in receiving objects for the fetch
- * identified by the 'Request ID'."
- * §5.2: "If the data stream is already open, it MAY send STOP_SENDING for
- * the data stream along with FETCH_CANCEL, but MUST send FETCH_CANCEL."
- *
- * draft-18: FETCH_CANCEL was REMOVED; cancellation is STOP_SENDING /
- * RESET_STREAM on the bidi request stream and the fetch data stream (§3.3.2).
- * No control message is sent.
- *
- * @param requestId The request ID of the fetch to cancel
- * @see draft-ietf-moq-transport-16 §5.2, §9.18; draft-ietf-moq-transport-18 §3.3.2
+ * @param requestId the Request ID that asked for the fill (a SUBSCRIBE's or a
+ * REQUEST_UPDATE's); a SUBSCRIBE's Request ID stops every fill of that subscription
+ */
+ async cancelFill(requestId: bigint): Promise {
+ const targets = new Set([requestId, ...this.session.expectedFillsOf(requestId)]);
+ for (const [fillId, fill] of this.fillStreams) {
+ if (fill.subscriptionId === requestId) targets.add(fillId);
+ }
+ const discards: Promise[] = [];
+ for (const fillId of targets) {
+ const open = this.fillStreams.get(fillId);
+ if (open === undefined) {
+ // One-shot marker: the FETCH_HEADER handler discards the late fill stream.
+ if (this.session.cancelFillRequest(fillId)) this.recentlyCancelledFetches.add(fillId);
+ this.pendingFillHeaders.get(fillId)?.finish(false);
+ continue;
+ }
+ this.fillStreams.delete(fillId);
+ const reader = this.dataStreamReaders.get(open.streamId);
+ if (reader) discards.push(this.discardLocalReader(open.streamId, reader, new Error('fill cancelled')));
+ }
+ await Promise.all(discards);
+ }
+
+ private settleFillHeaders(): void {
+ for (const [requestId, pending] of this.pendingFillHeaders) {
+ if (!this.session.isFillResponsePending(requestId)) {
+ pending.finish(this.session.fillSubscriptionFor(requestId) !== undefined);
+ }
+ }
+ }
+
+ private async waitForFillResponse(requestId: bigint, streamId: bigint): Promise {
+ if (this.pendingFillHeaders.has(requestId)) throw new ProtocolViolationError(`Second fill stream for request ${requestId}`);
+ if (this.pendingFillHeaders.size >= MoqtConnection.MAX_PENDING_ALIASES) return false;
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => finish(false), this.joiningFetchTimeoutMs);
+ const finish = (accepted: boolean): void => {
+ clearTimeout(timer);
+ this.pendingFillHeaders.delete(requestId);
+ resolve(accepted);
+ };
+ this.pendingFillHeaders.set(requestId, { streamId, finish });
+ });
+ }
+
+ /**
+ * Cancel an ordinary fetch. Draft-14/16 sends FETCH_CANCEL and stops its data
+ * stream. Draft-18/21 cancels the request and data streams without a control
+ * message. Use cancelFill() for a draft-21 fill, which has no FETCH request.
*/
async fetchCancel(requestId: bigint): Promise {
// §5.2: the fetch may already have ended (its data stream FIN'd/reset → reclaimed
@@ -3499,7 +3864,7 @@ export class MoqtConnection {
const actions = this.session.fetchCancel(requestId);
await this.executeActions(actions);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// §3.3.2: cancel the bidi request stream (STOP_SENDING + RESET_STREAM).
// This is a LOCAL cancel, so the request-stream response handler ignores
// the resulting RequestCancelledError (no onError, no unhandled rejection).
@@ -3546,7 +3911,7 @@ export class MoqtConnection {
name: Uint8Array,
): Promise {
const { requestId, actions } = this.session.trackStatus(namespace, name);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const msg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message;
await this.openD18Request(requestId, msg);
return requestId;
@@ -3590,7 +3955,7 @@ export class MoqtConnection {
*/
async publishNamespace(namespace: Uint8Array[]): Promise {
const { requestId, actions } = this.session.publishNamespace(namespace);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const msg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message;
await this.openD18Request(requestId, msg);
return requestId;
@@ -3613,7 +3978,7 @@ export class MoqtConnection {
*/
async publishNamespaceDone(requestId: bigint): Promise {
const actions = this.session.publishNamespaceDone(requestId);
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
// Terminate local state (no send_control action) and cancel the request
// stream — that cancellation IS the draft-18 withdrawal signal.
await this.executeActions(actions);
@@ -3659,6 +4024,7 @@ export class MoqtConnection {
);
}
const actions = this.session.acceptSubscribe(requestId, trackAlias, options);
+ this.finalizeInboundFill(requestId, options?.parameters);
// §5.1: the acceptance may itself force a session close (e.g. the superseded-set
// is at capacity, §5.1). Then there is no acceptance to report — execute the
// close, settle parked joins as NOT accepted, and reject rather than resolving
@@ -3676,7 +4042,7 @@ export class MoqtConnection {
// §10.11: authorize publisher data-plane ops (openSubgroup / sendObject /
// sendDatagram) on this alias ONLY for an inbound SUBSCRIBE we serve — never for
// an inbound PUBLISH, where we are the subscriber.
- if (!isPublishInitiated) this.publisherAliasRequests.set(trackAlias, requestId);
+ if (!isPublishInitiated) this.addPublisherAliasRequest(trackAlias, requestId);
// §8: for an inbound PUBLISH (we are the subscriber), COMPUTE the effective
// timeout by combining the publisher's Track-Property timeouts (captured at bind)
// with OUR PUBLISH_OK Message Parameters — but DEFER committing it until the
@@ -3761,14 +4127,15 @@ export class MoqtConnection {
* The caller re-throws so the failed acceptance is not reported as success.
*/
private async rollbackFailedAcceptance(requestId: bigint, trackAlias: bigint, isPublishInitiated: boolean): Promise {
- this.publisherAliasRequests.delete(trackAlias);
+ this.removePublisherAliasRequest(trackAlias, requestId);
// For an inbound PUBLISH, tear down its route AND arm a bounded late-object
// guard SYNCHRONOUSLY (before the first await), exactly like the inbound-FIN
// teardown — a subgroup opened BEFORE the failed PUBLISH_OK and delivered after
// would otherwise fall through to the connection-level onObject. Owner-checked
// so a reused alias's newer publication is untouched.
- const ownsRoute = isPublishInitiated
- && this.publishAliasMaps.get(trackAlias)?.requestId === requestId;
+ const ownsRoute = isPublishInitiated && this.publishAliasForRequest(requestId) === trackAlias
+ && !this.aliasHasOtherSubscriber(trackAlias, requestId);
+ if (isPublishInitiated) this.removePublishAliasMember(trackAlias, requestId);
if (ownsRoute) this.armTerminatedAlias(trackAlias, null, /* strict */ false); // drops route + installs guard
this.inboundPublishTimeouts.delete(requestId);
await this.executeActions(this.session.handleInboundRequestClosed(requestId));
@@ -3802,7 +4169,7 @@ export class MoqtConnection {
actions: SessionOutboundAction[],
finalize = false,
): Promise {
- if (this.session.draftVersion !== 18) return false;
+ if (!isRequestStreamDraft(this.session.draftVersion)) return false;
const ctx = this.inboundRequestContexts.get(requestId);
if (!ctx) return false;
const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined;
@@ -3840,7 +4207,9 @@ export class MoqtConnection {
// callback. Owner-checked so a reused alias's newer publication is untouched.
const incoming = this.session.getIncomingSubscription(requestId);
const pubAlias = incoming?.isPublishInitiated ? incoming.trackAlias : undefined;
- const ownsRoute = pubAlias !== undefined && this.publishAliasMaps.get(pubAlias)?.requestId === requestId;
+ const ownsRoute = pubAlias !== undefined && this.publishAliasForRequest(requestId) === pubAlias
+ && !this.aliasHasOtherSubscriber(pubAlias, requestId);
+ if (pubAlias !== undefined) this.removePublishAliasMember(pubAlias, requestId);
if (ownsRoute) this.armTerminatedAlias(pubAlias, null, /* strict */ false);
const actions = this.session.rejectSubscribe(requestId, errorCode, reason);
if (await this.writeInboundRequestResponse(requestId, actions)) {
@@ -3918,13 +4287,28 @@ export class MoqtConnection {
private detachFetchStreamsForRequest(requestId: bigint): WritableStreamDefaultWriter[] {
const writers: WritableStreamDefaultWriter[] = [];
for (const [streamId, st] of [...this.fetchOutgoingStreams]) {
- if (st.requestId !== requestId) continue;
- this.fetchOutgoingStreams.delete(streamId);
+ // §3.4.1: ending a subscription resets its open fill streams, including
+ // those a REQUEST_UPDATE asked for under its own Request ID.
+ if (st.requestId !== requestId && st.fillOf !== requestId) continue;
+ this.removeOutgoingFetchStream(streamId);
writers.push(st.writer);
}
return writers;
}
+ private removeOutgoingFetchStream(streamId: bigint): void {
+ const state = this.fetchOutgoingStreams.get(streamId);
+ this.fetchOutgoingStreams.delete(streamId);
+ if (state?.fillOf !== undefined) {
+ this.servedFills.delete(state.requestId);
+ this.inboundFetchGroupOrder.delete(state.requestId);
+ this.fetchServeReserved.delete(state.requestId);
+ const open = this.openSubgroupsByRequest.get(state.fillOf);
+ open?.delete(streamId);
+ if (open?.size === 0) this.openSubgroupsByRequest.delete(state.fillOf);
+ }
+ }
+
/** Detach (synchronously) + abort every open FETCH response stream for `requestId`. */
private async abortFetchStreamsForRequest(requestId: bigint, reason: string): Promise {
for (const writer of this.detachFetchStreamsForRequest(requestId)) {
@@ -4231,7 +4615,7 @@ export class MoqtConnection {
// subscription's own bidi request stream (an accepted inbound SUBSCRIBE),
// then the stream is sealed and fully closed (FIN + STOP_SENDING). Not the
// uni control stream.
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const ctx = this.inboundRequestContexts.get(requestId);
if (ctx) {
const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined;
@@ -4261,8 +4645,16 @@ export class MoqtConnection {
*/
private publisherSubscriptionForAlias(
trackAlias: bigint,
+ selectedRequestId?: bigint,
): { requestId: bigint; sub: SubscriptionStateMachine | undefined } | undefined {
- const requestId = this.publisherAliasRequests.get(trackAlias);
+ const requests = this.publisherAliasRequests.get(trackAlias);
+ if (selectedRequestId === undefined && requests && requests.size > 1) {
+ throw new MoqtConnectionError(`Track alias ${trackAlias} is shared; specify requestId for publisher ownership`, { errorSource: 'data' });
+ }
+ const requestId = selectedRequestId ?? requests?.values().next().value;
+ if (selectedRequestId !== undefined && !requests?.has(selectedRequestId)) {
+ return { requestId: selectedRequestId, sub: undefined };
+ }
if (requestId === undefined) {
// A recently-retired alias: still refuse sends (sub undefined) so a
// post-termination open is rejected, until it ages out of the bounded LRU.
@@ -4275,6 +4667,19 @@ export class MoqtConnection {
return { requestId, sub };
}
+ private addPublisherAliasRequest(alias: bigint, requestId: bigint): void {
+ const requests = this.publisherAliasRequests.get(alias) ?? new Set();
+ requests.add(requestId);
+ this.publisherAliasRequests.set(alias, requests);
+ this.retiredPublisherAliases.delete(alias);
+ }
+
+ private removePublisherAliasRequest(alias: bigint, requestId: bigint): void {
+ const requests = this.publisherAliasRequests.get(alias);
+ requests?.delete(requestId);
+ if (requests?.size === 0) this.publisherAliasRequests.delete(alias);
+ }
+
/** Publisher-side subscription state for an adapter-owned request ID. */
private publisherSubscriptionForRequest(requestId: bigint): SubscriptionStateMachine | undefined {
return this.session.getIncomingSubscription(requestId)
@@ -4288,9 +4693,11 @@ export class MoqtConnection {
* subscription churn stays bounded.
*/
private retirePublisherRequest(requestId: bigint): void {
- for (const [alias, rid] of this.publisherAliasRequests) {
- if (rid !== requestId) continue;
- this.publisherAliasRequests.delete(alias);
+ this.revokePublisherFills(requestId);
+ for (const [alias, requests] of this.publisherAliasRequests) {
+ if (!requests.has(requestId)) continue;
+ this.removePublisherAliasRequest(alias, requestId);
+ if (requests.size > 0) continue;
if (this.retiredPublisherAliases.size >= MoqtConnection.MAX_RETIRED_PUBLISHER_ALIASES) {
const oldest = this.retiredPublisherAliases.keys().next().value;
if (oldest !== undefined) this.retiredPublisherAliases.delete(oldest);
@@ -4304,6 +4711,18 @@ export class MoqtConnection {
// pending op so an in-flight generation is never lost.
}
+ private revokePublisherFills(requestId: bigint): void {
+ for (const [fillId, fill] of this.inboundFillRequests) {
+ if (fill.subscriptionId === requestId) this.inboundFillRequests.delete(fillId);
+ }
+ for (const [fillId, subscriptionId] of this.servedFills) {
+ if (subscriptionId !== requestId) continue;
+ this.servedFills.delete(fillId);
+ this.inboundFetchGroupOrder.delete(fillId);
+ this.fetchServeReserved.delete(fillId);
+ }
+ }
+
/** Cap publisherGeneration: evict the oldest entry that has no pending op. */
private boundPublisherGeneration(): void {
if (this.publisherGeneration.size <= MoqtConnection.MAX_RETIRED_PUBLISHER_ALIASES) return;
@@ -4322,8 +4741,9 @@ export class MoqtConnection {
private beginPublishOp(
trackAlias: bigint,
what: string,
+ requestId?: bigint,
): { requestId: bigint; sub: SubscriptionStateMachine; generation: number } {
- const assoc = this.publisherSubscriptionForAlias(trackAlias);
+ const assoc = this.publisherSubscriptionForAlias(trackAlias, requestId);
// The public API documents trackAlias as coming from acceptSubscribe() /
// publish(). An UNKNOWN alias (never associated, or aged out of the retired
// LRU) is rejected — never silently published with no accounting.
@@ -4452,9 +4872,10 @@ export class MoqtConnection {
const aborts: Array> = [];
if (open) {
for (const sid of [...open]) {
- const st = this.outgoingStreams.get(sid);
+ const st = this.outgoingStreams.get(sid) ?? this.fetchOutgoingStreams.get(sid);
if (st) {
this.outgoingStreams.delete(sid);
+ this.removeOutgoingFetchStream(sid);
// INITIATE every cancellation before awaiting any individual one.
// The FULFILMENT of each abort is what proves the stream was reset
// (§5.1.1), so a rejection is recorded, not swallowed.
@@ -4497,6 +4918,8 @@ export class MoqtConnection {
groupId: bigint,
subgroupId: bigint,
opts?: {
+ /** Required when concurrent subscriptions share this alias (draft 21). */
+ requestId?: bigint;
publisherPriority?: number;
hasExtensions?: boolean;
endOfGroup?: boolean;
@@ -4515,7 +4938,7 @@ export class MoqtConnection {
): Promise {
// §10.4.2 bit 6: FIRST_OBJECT (0x40) is a draft-18-only header bit — reject the
// option on draft-14/16 BEFORE opening a stream.
- if (opts?.firstObject && this.session.draftVersion !== 18) {
+ if (opts?.firstObject && !isRequestStreamDraft(this.session.draftVersion)) {
throw new MoqtConnectionError('openSubgroup: firstObject is a draft-18-only option', { errorSource: 'data' });
}
if (!this.transport?.createUnidirectionalStream) {
@@ -4529,7 +4952,7 @@ export class MoqtConnection {
// sees it in flight and refuses — the terminated check and the reservation
// are one atomic step, not a pre-await check that a deferred stream
// creation could slip past.
- const assoc = this.beginPublishOp(trackAlias, 'openSubgroup');
+ const assoc = this.beginPublishOp(trackAlias, 'openSubgroup', opts?.requestId);
try {
let writable: WritableStream;
try {
@@ -4573,6 +4996,7 @@ export class MoqtConnection {
const streamId = this.nextOutgoingStreamId++;
this.outgoingStreams.set(streamId, {
writer,
+ groupId,
hasExtensions: opts?.hasExtensions ?? false,
previousObjectId: 0n,
isFirstObject: true,
@@ -4605,7 +5029,7 @@ export class MoqtConnection {
// bits 1-2 are the subgroup-ID mode. draft-18 renamed EXTENSIONS→PROPERTIES
// (bit 0x01) vs draft-14/16 (0x20), so the flag bit differs by version.
const mode = opts?.subgroupIdMode ?? SubgroupIdMode.EXPLICIT;
- const d18 = this.session.draftVersion === 18;
+ const d18 = isRequestStreamDraft(this.session.draftVersion);
let typeByte = SubgroupFlags.SUBGROUP_MARKER | (mode << 1);
if (opts?.hasExtensions) typeByte |= d18 ? SubgroupFlags18.PROPERTIES : SubgroupFlags.EXTENSIONS;
if (opts?.endOfGroup) typeByte |= d18 ? SubgroupFlags18.END_OF_GROUP : SubgroupFlags.END_OF_GROUP;
@@ -4714,10 +5138,11 @@ export class MoqtConnection {
{ errorSource: 'data' },
);
}
+ this.assertPublisherLocation(sub, state.groupId, objectId);
}
const obj = { objectId, extensions, payload, status: undefined };
- const objectBytes = this.session.draftVersion === 18
+ const objectBytes = isRequestStreamDraft(this.session.draftVersion)
? encodeSubgroupObject18(obj, state.hasExtensions, state.previousObjectId, state.isFirstObject)
: encodeSubgroupObject(obj, state.hasExtensions, varint(state.previousObjectId), state.isFirstObject);
@@ -4744,9 +5169,9 @@ export class MoqtConnection {
groupId: bigint,
objectId: bigint,
payload: Uint8Array,
- opts?: { publisherPriority?: number },
+ opts?: { publisherPriority?: number; requestId?: bigint },
): Promise {
- if (this.session.draftVersion !== 18) {
+ if (!isRequestStreamDraft(this.session.draftVersion)) {
throw new MoqtConnectionError('sendDatagram is draft-18 only', { errorSource: 'data' });
}
if (!this.transport?.datagrams) {
@@ -4754,8 +5179,9 @@ export class MoqtConnection {
}
// §10.11: no datagrams for a terminated subscription; reserve the operation
// synchronously so a concurrent publishDone refuses (see openSubgroup).
- const assoc = this.beginPublishOp(trackAlias, 'sendDatagram');
+ const assoc = this.beginPublishOp(trackAlias, 'sendDatagram', opts?.requestId);
try {
+ if (assoc?.sub) this.assertPublisherLocation(assoc.sub, groupId, objectId);
const bytes = encodeObjectDatagram18({
typeByte: 0x00, // plain OBJECT_DATAGRAM: priority present, no flags
trackAlias,
@@ -4781,6 +5207,12 @@ export class MoqtConnection {
}
}
+ private assertPublisherLocation(sub: SubscriptionStateMachine, group: bigint, object: bigint): void {
+ if (isDraft21(this.session.draftVersion) && sub.locationWindow && !windowContains(sub.locationWindow, group, object)) {
+ throw new MoqtConnectionError(`Object ${group}/${object} is outside the subscription Location Filter`, { errorSource: 'data' });
+ }
+ }
+
/**
* Close an outgoing subgroup stream (sends FIN).
*
@@ -4843,7 +5275,7 @@ export class MoqtConnection {
* @see draft-ietf-moq-transport-18 §11.4.4
*/
async openFetchStream(requestId: bigint): Promise {
- if (this.session.draftVersion !== 18) {
+ if (!isRequestStreamDraft(this.session.draftVersion)) {
throw new MoqtConnectionError('openFetchStream is draft-18 only', { errorSource: 'data' });
}
const groupOrder = this.inboundFetchGroupOrder.get(requestId);
@@ -4862,7 +5294,17 @@ export class MoqtConnection {
throw new MoqtConnectionError(`FETCH ${requestId} already has its one response stream (§11.4.4)`, { errorSource: 'data' });
}
this.fetchServeReserved.add(requestId);
- const writer = (await this.transport.createUnidirectionalStream()).getWriter();
+ const fillOf = this.servedFills.get(requestId);
+ let stream: WritableStream;
+ try {
+ stream = await this.transport.createUnidirectionalStream();
+ } catch (err) {
+ // Allocation did not create a response stream, so the one-stream allowance
+ // remains available. Cancellation may already have revoked admission.
+ this.fetchServeReserved.delete(requestId);
+ throw err;
+ }
+ const writer = stream.getWriter();
// §10.13: authorization was validated BEFORE the createUnidirectionalStream
// await. A rejectFetch() or a request-stream close during that await
// deauthorizes the FETCH SYNCHRONOUSLY (its group-order entry is dropped before
@@ -4871,16 +5313,28 @@ export class MoqtConnection {
// RESURRECT itself. Abort the freshly-opened stream and refuse.
if (!this.inboundFetchGroupOrder.has(requestId)) {
this.fetchServeReserved.delete(requestId); // no stream established — release the reservation
- try { await writer.abort(new Error('inbound FETCH deauthorized during stream open')); } catch { /* already down */ }
+ await this.abortFetchWriter(writer, new Error('inbound FETCH deauthorized during stream open'), fillOf);
throw new MoqtConnectionError(`No admitted inbound FETCH for request ${requestId}`, { errorSource: 'data' });
}
+ const streamId = this.nextOutgoingStreamId++;
+ this.fetchOutgoingStreams.set(streamId, {
+ requestId, writer, groupOrder, prior: undefined, isFirstObject: true,
+ ...(fillOf === undefined ? {} : { fillOf }),
+ });
+ if (fillOf !== undefined) {
+ this.publisherSubscriptionForRequest(fillOf)!.incrementStreamCount();
+ const open = this.openSubgroupsByRequest.get(fillOf) ?? new Set();
+ open.add(streamId);
+ this.openSubgroupsByRequest.set(fillOf, open);
+ }
try {
await writer.write(encodeFetchHeader18(requestId));
} catch (err) {
// The FETCH_HEADER write failed: the stream is unusable — abort it so it is
// not left half-open, and surface the failure (do not register a dead writer).
this.fetchServeReserved.delete(requestId); // no stream established — release the reservation
- try { await writer.abort(err instanceof Error ? err : new Error(String(err))); } catch { /* already down */ }
+ await this.abortFetchWriter(writer, err instanceof Error ? err : new Error(String(err)), fillOf);
+ this.removeOutgoingFetchStream(streamId);
throw err instanceof Error ? err : new Error(String(err));
}
// Re-check once more after the header write (single-threaded: no await between
@@ -4888,18 +5342,110 @@ export class MoqtConnection {
// landed during the header write itself.
if (!this.inboundFetchGroupOrder.has(requestId)) {
this.fetchServeReserved.delete(requestId);
- try { await writer.abort(new Error('inbound FETCH deauthorized during stream open')); } catch { /* already down */ }
+ await this.abortFetchWriter(writer, new Error('inbound FETCH deauthorized during stream open'), fillOf);
+ this.removeOutgoingFetchStream(streamId);
throw new MoqtConnectionError(`No admitted inbound FETCH for request ${requestId}`, { errorSource: 'data' });
}
- const streamId = this.nextOutgoingStreamId++;
- this.fetchOutgoingStreams.set(streamId, { requestId, writer, groupOrder, prior: undefined, isFirstObject: true });
return streamId;
}
+ private async abortFetchWriter(writer: WritableStreamDefaultWriter, reason: Error, fillOf: bigint | undefined): Promise {
+ if (fillOf === undefined) {
+ try { await writer.abort(reason); } catch { /* already down */ }
+ return;
+ }
+ // A fill counts toward its subscription's terminal stream count. If its
+ // reset cannot be proved, closing the session prevents a false clean end.
+ const reset = Promise.resolve().then(() => writer.abort(reason)).then(() => true, () => false);
+ if (!await this.allFulfilledWithin([reset], MoqtConnection.PUBLISHER_ABORT_DEADLINE_MS)) {
+ this.closeSessionInternalError(`Fill for subscription ${fillOf} could not be reset`);
+ }
+ }
+
+ /**
+ * draft-21 §3.4: open the fill stream for an inbound SUBSCRIBE, or a
+ * REQUEST_UPDATE on one, that carried FILL_PARAMETERS. It is a fetch stream
+ * whose FETCH_HEADER carries that message's Request ID, written with {@link sendFetchObject} /
+ * {@link sendFetchEndOfRange} in the fill's effective group order; {@link closeFetchStream}
+ * FINs it (the fill is complete). There is no FETCH_OK. One per request.
+ */
+ async openFillStream(requestId: bigint): Promise {
+ const fill = this.inboundFillRequests.get(requestId);
+ if (fill === undefined) {
+ throw new MoqtConnectionError(`No pending fill for request ${requestId}`, { errorSource: 'data' });
+ }
+ const { subscriptionId, options } = fill;
+ const sub = this.publisherSubscriptionForRequest(subscriptionId);
+ if (!sub || sub.state !== SubscriptionState.ESTABLISHED || sub.forwardState === ForwardState.PAUSED) {
+ throw new MoqtConnectionError(`Fill ${requestId} has no active subscription`, { errorSource: 'data' });
+ }
+ if (fill.window === undefined) throw new MoqtConnectionError(`Fill ${requestId} has no response snapshot`, { errorSource: 'data' });
+ const assoc = this.beginPublishOp(sub.trackAlias!, 'openFillStream', subscriptionId);
+ this.inboundFillRequests.delete(requestId);
+ this.servedFills.set(requestId, subscriptionId);
+ this.inboundFetchGroupOrder.set(requestId, options.groupOrder ?? sub.groupOrder);
+ try {
+ const stream = await this.openFetchStream(requestId);
+ const state = this.fetchOutgoingStreams.get(stream);
+ if (!state) throw new MoqtConnectionError(`Fill ${requestId} was cancelled during stream open`, { errorSource: 'data' });
+ state.fillWindow = fill.window;
+ return stream;
+ } catch (err) {
+ // Only restore a fill that never acquired its stream. Header failures and
+ // cancellation reclaim servedFills themselves; neither may be retried.
+ if (this.servedFills.get(requestId) === subscriptionId
+ && this.inboundFetchGroupOrder.has(requestId)
+ && !this.fetchServeReserved.has(requestId)) {
+ this.servedFills.delete(requestId);
+ this.inboundFetchGroupOrder.delete(requestId);
+ this.inboundFillRequests.set(requestId, fill);
+ }
+ throw err;
+ } finally {
+ this.endPublishOp(assoc.requestId);
+ }
+ }
+
+ private rememberInboundFill(requestId: bigint, subscriptionId: bigint, parameters: Parameters): boolean {
+ const bytes = parameters.get(FILL_PARAMETERS)?.[0];
+ const sub = this.publisherSubscriptionForRequest(subscriptionId);
+ if (!(bytes instanceof Uint8Array) || !sub || sub.forwardState !== ForwardState.ACTIVE) return true;
+ if (this.inboundFillRequests.size + this.servedFills.size >= MoqtConnection.MAX_PENDING_FILLS) {
+ this.closeSessionInternalError('Too many outstanding fill streams');
+ return false;
+ }
+ const options = decodeFillParameters(bytes);
+ const filter = options.filter ?? (sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21));
+ const groupOrder = options.groupOrder ?? sub.requestedGroupOrder;
+ this.inboundFillRequests.set(requestId, {
+ subscriptionId,
+ options: {
+ ...(filter === undefined ? {} : { filter }),
+ ...(groupOrder === undefined ? {} : { groupOrder }),
+ },
+ });
+ return true;
+ }
+
+ private finalizeInboundFill(requestId: bigint, parameters?: Parameters): void {
+ const fill = this.inboundFillRequests.get(requestId);
+ if (!fill) return;
+ const value = parameters?.get(0x09n)?.[0];
+ const largest = value !== null && typeof value === 'object' && 'group' in value && 'object' in value
+ ? value as { group: bigint; object: bigint } : undefined;
+ const window = resolveFillWindow(fill.options.filter, largest);
+ if (window === null) this.inboundFillRequests.delete(requestId);
+ else fill.window = window;
+ }
+
/** Send a normal fetch object on a FETCH response stream (§11.4.4). */
async sendFetchObject(streamId: bigint, fields: FetchObjectFields): Promise {
const state = this.fetchOutgoingStreams.get(streamId);
if (!state) throw new MoqtConnectionError(`Unknown fetch stream ${streamId}`, { errorSource: 'data' });
+ if (state.closing) throw new MoqtConnectionError(`Fetch stream ${streamId} is closing`, { errorSource: 'data' });
+ if (state.fillWindow && !windowContains(state.fillWindow, fields.groupId, fields.objectId)) {
+ throw new MoqtConnectionError(`Object ${fields.groupId}/${fields.objectId} is outside the fill range`, { errorSource: 'data' });
+ }
const { bytes, nextPrior } = encodeFetchObject18(fields, state.prior, state.isFirstObject, state.groupOrder);
await state.writer.write(bytes);
state.prior = nextPrior;
@@ -4910,7 +5456,12 @@ export class MoqtConnection {
async sendFetchEndOfRange(streamId: bigint, nonExistent: boolean, groupId: bigint, objectId: bigint): Promise {
const state = this.fetchOutgoingStreams.get(streamId);
if (!state) throw new MoqtConnectionError(`Unknown fetch stream ${streamId}`, { errorSource: 'data' });
- const { bytes, nextPrior } = encodeFetchEndOfRange18(nonExistent, groupId, objectId, state.prior);
+ if (state.closing) throw new MoqtConnectionError(`Fetch stream ${streamId} is closing`, { errorSource: 'data' });
+ if (state.fillWindow && !windowContains(state.fillWindow, groupId, objectId)) {
+ throw new MoqtConnectionError(`Range end ${groupId}/${objectId} is outside the fill range`, { errorSource: 'data' });
+ }
+ const { bytes, nextPrior } = encodeFetchEndOfRange18(nonExistent, groupId, objectId, state.prior,
+ isDraft21(this.session.draftVersion) ? 21 : 18);
await state.writer.write(bytes);
state.prior = nextPrior;
state.isFirstObject = false;
@@ -4920,6 +5471,21 @@ export class MoqtConnection {
async closeFetchStream(streamId: bigint): Promise {
const state = this.fetchOutgoingStreams.get(streamId);
if (!state) return;
+ if (state.closing) return state.closing;
+ if (state.fillOf !== undefined) {
+ const fin = (async () => {
+ try { await state.writer.close(); }
+ catch (err) {
+ this.closeSessionInternalError(`Fill ${state.requestId} could not finish: ${String(err)}`);
+ throw err;
+ } finally {
+ this.removeOutgoingFetchStream(streamId);
+ this.inboundFetchGroupOrder.delete(state.requestId);
+ }
+ })();
+ state.closing = fin;
+ return fin;
+ }
try { await state.writer.close(); } catch { /* already closed */ }
this.fetchOutgoingStreams.delete(streamId);
// §10.13: the publisher finished serving — RECLAIM the accepted incoming fetch in
@@ -5015,7 +5581,7 @@ export class MoqtConnection {
}
this.pendingGenericSubscriptions.delete(action.requestId);
this.settlePendingAliasOwnership(action.requestId);
- if (this.session.draftVersion === 18) await this.uniPair?.cancelRequest(action.requestId);
+ if (isRequestStreamDraft(this.session.draftVersion)) await this.uniPair?.cancelRequest(action.requestId);
break;
}
case 'close_stream':
@@ -5185,7 +5751,7 @@ export class MoqtConnection {
onObject: null,
onSubgroupClosed: null,
};
- this.publishAliasMaps.set(incoming.trackAlias, incoming);
+ this.bindPublishAlias(incoming);
this.onPublish(incoming);
}
}
@@ -5480,9 +6046,14 @@ export class MoqtConnection {
// — but only for the route THIS request still owns (found by requestId), so
// a reused alias's newer publication is untouched. Captured before the
// session teardown below (which unregisters the session alias).
- const pubAlias = ctx.openerKind === 'publish'
- ? [...this.publishAliasMaps].find(([, p]) => p.requestId === requestId)?.[0]
- : undefined;
+ const pubAlias = ctx.openerKind === 'publish' ? this.publishAliasForRequest(requestId) : undefined;
+ const endsAlias = pubAlias !== undefined && !this.aliasHasOtherSubscriber(pubAlias, requestId)
+ && ![...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === pubAlias);
+ if (pubAlias !== undefined) {
+ this.recordSubscriberTerminal(pubAlias, null);
+ this.removePublishAliasMember(pubAlias, requestId);
+ }
+ const cancelledFills = pubAlias === undefined ? Promise.resolve() : this.cancelFill(requestId);
// SYNCHRONOUS route removal + tombstone install + FETCH deauthorization
// (before the FIRST await) so nothing can slip through the teardown window:
// - a late object can't route (armTerminatedAlias drops publishAliasMaps);
@@ -5490,18 +6061,20 @@ export class MoqtConnection {
// cancelled FETCH — its group-order authorization is gone before we await,
// and its post-await re-check now fails (§10.13). Deauthorizing only AFTER
// the session-teardown await left a window in which a new stream opened.
- if (pubAlias !== undefined) this.armTerminatedAlias(pubAlias, null, /* strict */ false);
+ if (endsAlias) this.armTerminatedAlias(pubAlias!, null, /* strict */ false);
this.inboundFetchGroupOrder.delete(requestId);
this.fetchServeReserved.delete(requestId); // the one-stream reservation is released on teardown
+ this.revokePublisherFills(requestId);
this.inboundPublishTimeouts.delete(requestId); // never-accepted inbound PUBLISH
// §10.13: DETACH every open FETCH response stream SYNCHRONOUSLY (before the
// first await), so sendFetchObject()/closeFetchStream() for this cancelled
// fetch fail immediately rather than continuing to write during the teardown
// awaits below. The writers are aborted afterward.
- const fetchWriters = this.detachFetchStreamsForRequest(requestId);
+ const fetchWriters = wasSubscribe ? [] : this.detachFetchStreamsForRequest(requestId);
await this.executeActions(this.session.handleInboundRequestClosed(requestId));
+ await cancelledFills;
this.inboundRequestContexts.delete(requestId);
- if (pubAlias !== undefined) await this.discardOpenStreamsForAlias(pubAlias);
+ if (endsAlias) await this.discardOpenStreamsForAlias(pubAlias!);
// §5.1.1: the subscriber cancelled — RESET every publisher data stream
// still open for this subscription and drop its accounting, so no more
// objects can be written for a subscription the peer abandoned.
@@ -5597,6 +6170,15 @@ export class MoqtConnection {
}
const originalId = ctx.requestId;
+ if (message.type === 'PUBLISH_STATE_NOTIFY') {
+ if (ctx.openerKind !== 'publish' || this.session.getIncomingSubscription(originalId)?.state !== SubscriptionState.ESTABLISHED) {
+ throw new ProtocolViolationError('PUBLISH_STATE_NOTIFY requires an established inbound PUBLISH (9.10)');
+ }
+ const stamped = { ...message, requestId: originalId } as ControlMessage;
+ await this.executeActions(this.session.handleControlMessage(stamped));
+ if (!this._terminated) this.onMessage?.(stamped);
+ return;
+ }
if (message.type === 'PUBLISH_DONE') {
// §10.11: terminate the subscription. Only an inbound PUBLISH carries a
// PUBLISH_DONE; on any other opener it is a protocol violation.
@@ -5607,22 +6189,29 @@ export class MoqtConnection {
// then apply the Stream Count so open/late streams are STOP_SENDINGed and
// the alias tombstone is cleared once the count is met (or the TTL fires),
// rather than retaining the publication's routing indefinitely.
- const doneAlias = [...this.publishAliasMaps].find(([, p]) => p.requestId === originalId)?.[0];
- const streamCount = (message as { streamCount?: bigint }).streamCount ?? 0n;
+ const alias = this.publishAliasForRequest(originalId);
+ const aliasStillShared = alias !== undefined && this.aliasHasOtherSubscriber(alias, originalId);
+ const doneAlias = aliasStillShared ? undefined : alias;
+ if (aliasStillShared) this.removePublishAliasMember(alias!, originalId);
+ const ownCount = (message as { streamCount?: bigint }).streamCount ?? 0n;
+ const streamCount = alias === undefined ? ownCount : this.recordSubscriberTerminal(alias, ownCount);
const stamped = { ...message, requestId: originalId } as ControlMessage;
const pubDraining = this.drainConfigs.has(originalId) && doneAlias !== undefined;
- if (pubDraining) this.applyTerminalDrain(doneAlias!, BigInt(streamCount), originalId);
+ const aliasDraining = pubDraining || (doneAlias !== undefined && [...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === doneAlias));
+ if (aliasDraining && doneAlias !== undefined) this.applyTerminalDrain(doneAlias, BigInt(streamCount), originalId);
+ const cancelledFills = pubDraining ? Promise.resolve() : this.cancelFill(originalId);
// Non-drained terminal application starts BEFORE the callback, like the
// drain above: its arm runs before the first await, so a reentrant
// close() inside the callback CLEARS the terminal state instead of having
// its cleanup undone by an arm that follows it.
- const discard = !pubDraining && doneAlias !== undefined
+ const discard = !aliasDraining && doneAlias !== undefined
? this.applyTerminalStreamCount(doneAlias, BigInt(streamCount), originalId)
: Promise.resolve();
this.onMessage?.(stamped);
await this.executeActions(this.session.handleInboundPublishDone(originalId));
ctx.seal();
this.inboundRequestContexts.delete(originalId);
+ await cancelledFills;
await discard;
return;
}
@@ -5637,6 +6226,18 @@ export class MoqtConnection {
// session routes it by the opener — a PUBLISH subscription update, or a
// §10.9.2 SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS prefix update.
const updateId = (message as { requestId: bigint }).requestId;
+ // draft-21 §9.1.7: updates are answered at once except on a still-pending
+ // SUBSCRIBE, where the answers queue behind SUBSCRIBE_OK. A peer that sends
+ // one more than our MAX_REQUEST_UPDATES while they queue closes the session.
+ const ownMaxUpdates = this.session.ownMaxRequestUpdates;
+ if (isDraft21(this.session.draftVersion) && ownMaxUpdates > 0n
+ && BigInt(this.deferredUpdateResponses.get(originalId)?.length ?? 0) >= ownMaxUpdates) {
+ const reason = `more than MAX_REQUEST_UPDATES (${ownMaxUpdates}) unanswered REQUEST_UPDATEs on request ${originalId}`;
+ const closeActions = this.session.close(SessionError.TOO_MANY_REQUEST_UPDATES, reason);
+ await this.executeActions(closeActions);
+ this.notifyClose(closeActions[0] as CloseConnectionAction, reason);
+ return;
+ }
const stamped = { ...message, existingRequestId: originalId } as ControlMessage;
this.onMessage?.(stamped);
const actions = this.session.handleControlMessage(stamped, {
@@ -5653,6 +6254,14 @@ export class MoqtConnection {
return;
}
const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined;
+ // draft-21 §3.4: an accepted update carrying FILL_PARAMETERS asks for a fill
+ // under the update's own Request ID (served with openFillStream).
+ if (isDraft21(this.session.draftVersion) && ctx.openerKind === 'subscribe'
+ && send?.message.type !== 'REQUEST_ERROR'
+ && (message as { parameters?: Map }).parameters?.has(FILL_PARAMETERS)) {
+ if (!this.rememberInboundFill(updateId, originalId, (message as { parameters: Parameters }).parameters)) return;
+ if (send?.message.type === 'REQUEST_OK') this.finalizeInboundFill(updateId, send.message.parameters);
+ }
// §10.8: SUBSCRIBE_OK is the FIRST response on a successful subscribe
// stream. An update racing a still-PENDING subscription is APPLIED
// immediately (§10.12.2), but its REQUEST_OK is deferred and flushed —
@@ -5775,7 +6384,7 @@ export class MoqtConnection {
onObject: null,
onSubgroupClosed: null,
};
- this.publishAliasMaps.set(incoming.trackAlias, incoming);
+ this.bindPublishAlias(incoming);
// §8 applies to a PUBLISH-initiated subscription too. Capture the publisher's raw
// OBJECT/SUBGROUP_DELIVERY_TIMEOUT Track Properties so acceptSubscribe can COMBINE
// them with OUR PUBLISH_OK Message Parameters; set a provisional (publisher-only)
@@ -5833,6 +6442,9 @@ export class MoqtConnection {
await this.executeActions(actions);
ctx.bind(requestId, 'subscribe');
this.inboundRequestContexts.set(requestId, ctx);
+ if (isDraft21(this.session.draftVersion) && sub.parameters.has(FILL_PARAMETERS)) {
+ if (!this.rememberInboundFill(requestId, requestId, sub.parameters)) return;
+ }
this.onSubscribe?.(requestId, sub.trackNamespace, sub.trackName, sub.parameters as Map);
}
@@ -5923,7 +6535,7 @@ export class MoqtConnection {
// forward-state rule for joins: §9.16.2 permits association with a
// Pending subscription and requires only the Largest Object filter, so
// a legal pending d16 join must not be rejected here.
- if (this.session.draftVersion === 18) {
+ if (isRequestStreamDraft(this.session.draftVersion)) {
const sub = this.session.getIncomingSubscription(subRequestId);
if (!sub || sub.forwardState !== ForwardState.ACTIVE) {
try {
@@ -6093,6 +6705,28 @@ export class MoqtConnection {
await this.processDataStreamReader(stream.reader, streamId, stream.prefix);
}
+ /** Both subgroup and fill streams count toward the alias's terminal budget. */
+ private admitSubscriberStream(alias: bigint): string | undefined {
+ const drain = this.drainingAliases.get(alias);
+ if (drain) {
+ if (drain.drained) return 'stream during terminal-drain completion';
+ if (drain.acceptRemaining !== null) {
+ if (drain.acceptRemaining <= 0n) return 'stream beyond the announced Stream Count';
+ drain.acceptRemaining -= 1n;
+ }
+ }
+ const tombstone = this.terminatedAliases.get(alias);
+ if (tombstone) {
+ if (tombstone.remaining !== null) {
+ tombstone.remaining -= 1n;
+ if (tombstone.remaining <= 0n) this.clearTerminatedAlias(alias);
+ }
+ return 'late stream on a terminated alias';
+ }
+ this.aliasStreamsSeen.set(alias, (this.aliasStreamsSeen.get(alias) ?? 0n) + 1n);
+ return undefined;
+ }
+
private async processDataStreamReader(
reader: ReadableStreamDefaultReader,
streamId: bigint,
@@ -6139,50 +6773,12 @@ export class MoqtConnection {
if (headerResult.type === 'subgroup') {
const header = headerResult.header as SubgroupHeader;
const subgroupAlias = header.trackAlias as bigint;
- // §10.11 receiver terminal tracker: a stream for an alias whose
- // PUBLISH_DONE already arrived is a LATE expected stream — early-discard
- // it (STOP_SENDING), count it against the announced total, and clear the
- // tombstone once the whole count is observed. Never delivered.
- // Terminal drain: a draining alias still DELIVERS late streams, capped
- // by the announced Stream Count. Beyond the cap, discard like a
- // tombstoned stream (but without the tombstone countdown).
- const drainState = this.drainingAliases.get(subgroupAlias);
- if (drainState) {
- // A completing drain (sweep in progress) no longer delivers: discard,
- // closing the race between `drained` flipping and the tombstone
- // being installed after the sweep's awaits.
- if (drainState.drained) {
- await this.discardLocalReader(streamId, reader, new Error('stream during terminal-drain completion — discarded'));
- this.onStreamClosed?.(streamId, undefined, 'local-discard');
- return;
- }
- if (drainState.acceptRemaining !== null) {
- if (drainState.acceptRemaining <= 0n) {
- await this.discardLocalReader(streamId, reader, new Error('stream beyond the announced Stream Count during terminal drain — discarded'));
- this.onStreamClosed?.(streamId, undefined, 'local-discard');
- return;
- }
- drainState.acceptRemaining -= 1n;
- }
- // fall through: deliver normally (routing is still alive)
- }
- const tombstone = this.terminatedAliases.get(subgroupAlias);
- if (tombstone) {
- // Count down only a KNOWN remaining (bigint); a null remaining is the
- // 2^62-1 sentinel — unknown count, cleared by TTL alone. Clear as soon
- // as the known count is met so a legitimately reused alias isn't held.
- if (tombstone.remaining !== null) {
- tombstone.remaining -= 1n;
- if (tombstone.remaining <= 0n) this.clearTerminatedAlias(subgroupAlias);
- }
- await this.discardLocalReader(streamId, reader, new Error('late stream on a terminated alias — early discard (§10.11)'));
+ const discardReason = this.admitSubscriberStream(subgroupAlias);
+ if (discardReason) {
+ await this.discardLocalReader(streamId, reader, new Error(discardReason));
this.onStreamClosed?.(streamId, undefined, 'local-discard');
return;
}
- // Count the stream toward this alias's lifetime total (so a later
- // PUBLISH_DONE knows how many streams are still outstanding) and track
- // it as open so the terminal message can STOP_SENDING it.
- this.aliasStreamsSeen.set(subgroupAlias, (this.aliasStreamsSeen.get(subgroupAlias) ?? 0n) + 1n);
this.incomingSubgroupAliases.set(streamId, subgroupAlias);
// Header decoded — route objects below
this.onQlogEvent?.({
@@ -6218,6 +6814,43 @@ export class MoqtConnection {
} else {
const header = headerResult.header as FetchHeader;
const fetchReqId = header.requestId as bigint;
+ if (this.session.isFillResponsePending(fetchReqId)) {
+ if (!await this.waitForFillResponse(fetchReqId, streamId)) {
+ this.session.cancelFillRequest(fetchReqId);
+ this.recentlyCancelledFetches.delete(fetchReqId);
+ await this.discardLocalReader(streamId, reader, new Error('fill response unavailable'));
+ this.onStreamClosed?.(streamId, undefined, 'local-discard');
+ return;
+ }
+ }
+ // draft-21 §3.4: a fill fetch stream carries the Request ID of the SUBSCRIBE
+ // or REQUEST_UPDATE that asked for it. One stream per fill; the session
+ // stops expecting it once this one is accepted.
+ const fillOrder = this.session.fillGroupOrderFor(fetchReqId);
+ const fillSub = isDraft21(this.session.draftVersion) && !this.recentlyCancelledFetches.has(fetchReqId)
+ ? this.session.takeFill(fetchReqId)
+ : undefined;
+ if (fillSub !== undefined) {
+ if (fillSub.trackAlias !== undefined) {
+ const discardReason = this.admitSubscriberStream(fillSub.trackAlias);
+ if (discardReason) {
+ await this.discardLocalReader(streamId, reader, new Error(discardReason));
+ this.onStreamClosed?.(streamId, undefined, 'local-discard');
+ return;
+ }
+ }
+ this.fillStreams.set(fetchReqId, { streamId, subscriptionId: fillSub.requestId as bigint, groupOrder: fillOrder! });
+ this.onQlogEvent?.({ type: 'stream_type_set', owner: 'remote', stream_id: streamId, stream_type: 'fetch_header' });
+ this.onQlogEvent?.({ type: 'fetch_header_parsed', stream_id: streamId, request_id: fetchReqId });
+ if (this.locallyDiscardedReaders.has(reader)) {
+ this.onStreamClosed?.(streamId, undefined, 'local-discard');
+ return;
+ }
+ this.onDataStream?.(streamId, { type: 'fetch', header, fill: true });
+ readTerminal = await this.readFetchObjects18(reader, buf, streamId, header);
+ this.onStreamClosed?.(streamId, undefined, terminalKind(readTerminal));
+ return;
+ }
// §10.13: a data stream for a fetch WE cancelled may still be in flight. It is
// NOT a new fetch nor a §11.4.4 duplicate — DISCARD it (STOP_SENDING via the
// finally's reader cleanup) and CONSUME the one-shot marker (a cancellation
@@ -6269,7 +6902,7 @@ export class MoqtConnection {
// Phase 2: Decode fetch objects. draft-18 has its own object format
// (group-order-aware deltas, End-of-Range prior rules) and correlates by
// FETCH_HEADER.requestId — not by a (fabricated) track alias.
- readTerminal = this.dataCodec!.version === 18
+ readTerminal = isRequestStreamDraft(this.dataCodec!.version)
? await this.readFetchObjects18(reader, buf, streamId, header)
: await this.readFetchObjects(reader, buf, streamId);
}
@@ -6329,6 +6962,12 @@ export class MoqtConnection {
// response stream per fetch) — a cancelled fetch is instead recognized by its
// lossless cancellation marker and its late stream discarded; a completed one
// has no marker, so a second stream is correctly rejected.
+ for (const [reqId, fill] of this.fillStreams) {
+ if (fill.streamId === streamId) {
+ this.fillStreams.delete(reqId);
+ break;
+ }
+ }
for (const [reqId, sid] of this.fetchStreams) {
if (sid === streamId) {
this.fetchStreams.delete(reqId);
@@ -6697,7 +7336,10 @@ export class MoqtConnection {
// group-order map is recorded for every fetch() (Ascending by default), so a
// missing entry means an unknown/unrequested fetch — a PROTOCOL_VIOLATION,
// never a silently-defaulted decode.
- const groupOrder = this.fetchGroupOrder.get(header.requestId);
+ // A draft-21 fill is delivered in ascending order (§3.4); a FETCH records its own.
+ const groupOrder = this.fetchGroupOrder.get(header.requestId)
+ ?? (this.fillStreams.get(header.requestId as bigint)?.streamId === streamId
+ ? this.fillStreams.get(header.requestId as bigint)!.groupOrder : undefined);
if (groupOrder === undefined) {
// No group-order entry AND no cancellation marker (the marker is checked — and
// consumed — at the FETCH_HEADER handler and the loop-top guard BEFORE we reach
diff --git a/packages/webtransport/src/testkit/loopback.ts b/packages/webtransport/src/testkit/loopback.ts
index bcd69792..e0b5ccdc 100644
--- a/packages/webtransport/src/testkit/loopback.ts
+++ b/packages/webtransport/src/testkit/loopback.ts
@@ -223,7 +223,7 @@ export class LoopPipe {
/** One endpoint of a loopback pair. Streams it opens surface on the peer. */
export class LoopbackTransport implements WebTransportLike {
/** No negotiated protocol — callers pass the draft version explicitly. */
- readonly protocol: string | undefined = undefined;
+ readonly protocol?: string;
/** Outbound uni streams WE opened (uniOut[0] is our control stream). */
readonly uniOut: LoopPipe[] = [];
diff --git a/packages/webtransport/src/testkit/pair.ts b/packages/webtransport/src/testkit/pair.ts
index f2cd5653..5972d8df 100644
--- a/packages/webtransport/src/testkit/pair.ts
+++ b/packages/webtransport/src/testkit/pair.ts
@@ -9,7 +9,7 @@
import { MoqtConnection } from '../adapter.js';
import { createLoopback, type LoopbackTransport, type PipeFaults } from './loopback.js';
import type { WebTransportLike } from '../types.js';
-import { varint, type DraftVersion, type SetupOptions } from '@openmoq/transport';
+import { varint, isRequestStreamDraft, type DraftVersion, type SetupOptions } from '@openmoq/transport';
/** Build a single-field Track Namespace tuple from a string. */
export const ns = (s: string): Uint8Array[] => [new TextEncoder().encode(s)];
@@ -61,6 +61,8 @@ export async function connectedPair(
faults?: { a?: PipeFaults; b?: PipeFaults };
serverOptions?: { joiningFetchTimeoutMs?: number; terminatedAliasTtlMs?: number };
clientOptions?: { joiningFetchTimeoutMs?: number; terminatedAliasTtlMs?: number };
+ /** Extra Setup Options the server advertises (e.g. draft-21 maxRequestUpdates). */
+ serverSetup?: SetupOptions;
} = {},
): Promise {
const { a, b } = createLoopback(opts.faults ?? {});
@@ -78,7 +80,10 @@ export async function connectedPair(
// which never happen here. draft-18 has no request credit (the option is
// ignored there), so both sides get an empty setup.
const clientSetup: SetupOptions = {};
- const serverSetup: SetupOptions = version === 18 ? {} : { maxRequestId: varint(1_000_000n) };
+ const serverSetup: SetupOptions = {
+ ...(isRequestStreamDraft(version) ? {} : { maxRequestId: varint(1_000_000n) }),
+ ...(opts.serverSetup ?? {}),
+ };
// Both endpoints establish concurrently — neither can complete before the other
// starts (each must read the peer's SETUP).
await Promise.all([client.connect(a, clientSetup), server.connect(b, serverSetup)]);
diff --git a/packages/webtransport/src/testkit/scenario.ts b/packages/webtransport/src/testkit/scenario.ts
index 06da6c3d..a34d75b2 100644
--- a/packages/webtransport/src/testkit/scenario.ts
+++ b/packages/webtransport/src/testkit/scenario.ts
@@ -29,6 +29,7 @@ import { flush, type PipeFaults } from './loopback.js';
import { makePrng } from './prng.js';
import { fnv1a64, type TraceRecord } from './trace-hash.js';
import type { TrackSubscription } from '../adapter.js';
+import { isRequestStreamDraft } from '@openmoq/transport';
/** Stable op codes (folded into the trace hash). */
export const Op = {
@@ -219,7 +220,7 @@ export async function runScenario(opts: ScenarioOptions): Promise void | Promise;
- constructor(private readonly session: Session) {}
+ constructor(private readonly session: Session) {
+ this.version = session.draftVersion === 21 ? 21 : 18;
+ this.codec = createControlCodec(this.version);
+ }
/**
* Establish the draft-18 control-stream pair: open our outbound uni control
@@ -970,6 +974,22 @@ class RequestStreamContext {
await this.onPeerRequest?.(message);
continue;
}
+ // draft-21 §9.10: PUBLISH_STATE_NOTIFY is a publisher's report on a
+ // subscription's request stream, after SUBSCRIBE_OK. Not a response to
+ // any local operation — never FIFO-matched. Anywhere else it is a
+ // protocol violation.
+ if (message.type === 'PUBLISH_STATE_NOTIFY') {
+ if (this.openerType !== 'SUBSCRIBE') {
+ throw new ProtocolViolationError(
+ `PUBLISH_STATE_NOTIFY not accepted on a ${this.openerType} request stream`,
+ );
+ }
+ if (!this.subscribeEstablished) {
+ throw new ProtocolViolationError('PUBLISH_STATE_NOTIFY without a preceding SUBSCRIBE_OK');
+ }
+ await this.onPeerRequest?.(message);
+ continue;
+ }
// §10.11: PUBLISH_DONE is the publisher's TERMINAL message on a
// subscription's request stream — accepted exactly once, only after
// a successful SUBSCRIBE_OK (never after REQUEST_ERROR), and only
diff --git a/tools/moq-interop-client/src/main.ts b/tools/moq-interop-client/src/main.ts
index 2c2be562..6e1bdcce 100644
--- a/tools/moq-interop-client/src/main.ts
+++ b/tools/moq-interop-client/src/main.ts
@@ -51,7 +51,7 @@ export function __setBoundScale(v: number) { BOUND_SCALE = v; }
/** MOQT_DRAFT is THE draft selector. The old scaffold read DRAFT_VERSION while
* compose sets MOQT_DRAFT, so a planned draft-16 row silently ran the default.
* A non-empty unsupported value fails closed rather than defaulting. */
-const SUPPORTED_DRAFTS = [18, 16] as const;
+const SUPPORTED_DRAFTS = [18, 16, 21] as const;
function selectDraft(): number {
const raw = (process.env.MOQT_DRAFT ?? "").trim();
if (raw === "") return 18;
@@ -63,6 +63,7 @@ function selectDraft(): number {
}
/** Over WebTransport these are WT-Available-Protocols values, not QUIC ALPNs. */
function protocolsFor(draft: number): string[] | undefined {
+ if (draft === 21) return ["moqt-21"];
if (draft === 18) return ["moqt-18"];
if (draft === 16) return ["moqt-16"];
return undefined; // draft 14: plain h3 + in-band CLIENT_SETUP
diff --git a/tsconfig.test.json b/tsconfig.test.json
index 95c84c1b..ce3fbf07 100644
--- a/tsconfig.test.json
+++ b/tsconfig.test.json
@@ -12,12 +12,15 @@
"rootDir": ".",
"types": ["node"]
},
- // Scoped to simulation suites, test-support helpers, and repository scripts. The wider
+ // Scoped to simulation suites, test-support helpers, repository scripts,
+ // and draft-21 conformance tests. The wider
// `*.test.ts` corpus predates any typecheck gate and does not compile clean
// under these options; widening this include is a separate cleanup.
"include": [
"packages/*/src/**/*.sim.test.ts",
"packages/*/test-support/**/*.ts",
+ "packages/transport/src/session/session-d21-conformance.test.ts",
+ "packages/webtransport/src/adapter-d21-conformance.test.ts",
"scripts/**/*.test.ts",
"_migration/**/*.test.ts"
]