@norskvideo/moq-net 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/announced.d.ts +111 -21
- package/announced.d.ts.map +1 -1
- package/announced.js +213 -40
- package/announced.js.map +1 -1
- package/broadcast.d.ts +80 -25
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +270 -49
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +6 -0
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +42 -13
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts +5 -0
- package/connection/browser.d.ts.map +1 -0
- package/connection/browser.js +31 -0
- package/connection/browser.js.map +1 -0
- package/connection/connect.d.ts +43 -1
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +118 -123
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +43 -16
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/handshake.d.ts +12 -1
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +30 -14
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +8 -0
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +6 -0
- package/connection/index.js.map +1 -1
- package/connection/reload.d.ts +66 -9
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +192 -66
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +92 -0
- package/connection/stats.d.ts.map +1 -0
- package/connection/stats.js +35 -0
- package/connection/stats.js.map +1 -0
- package/connection/transport.d.ts +10 -0
- package/connection/transport.d.ts.map +1 -0
- package/connection/transport.js +12 -0
- package/connection/transport.js.map +1 -0
- package/consume.d.ts +44 -0
- package/consume.d.ts.map +1 -0
- package/consume.js +60 -0
- package/consume.js.map +1 -0
- package/datagram.d.ts +23 -0
- package/datagram.d.ts.map +1 -0
- package/datagram.js +3 -0
- package/datagram.js.map +1 -0
- package/error.d.ts +76 -0
- package/error.d.ts.map +1 -0
- package/error.js +107 -0
- package/error.js.map +1 -0
- package/group.d.ts +143 -38
- package/group.d.ts.map +1 -1
- package/group.js +270 -68
- package/group.js.map +1 -1
- package/ietf/adapter.d.ts +2 -2
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +20 -10
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +73 -5
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/aliases.js +121 -14
- package/ietf/aliases.js.map +1 -1
- package/ietf/cluster.d.ts +141 -0
- package/ietf/cluster.d.ts.map +1 -0
- package/ietf/cluster.js +204 -0
- package/ietf/cluster.js.map +1 -0
- package/ietf/connection.d.ts +39 -6
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +73 -12
- package/ietf/connection.js.map +1 -1
- package/ietf/fetch.d.ts +17 -0
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +21 -0
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +87 -0
- package/ietf/filter.d.ts.map +1 -0
- package/ietf/filter.js +292 -0
- package/ietf/filter.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +3 -0
- package/ietf/index.js.map +1 -1
- package/ietf/namespace.d.ts +6 -0
- package/ietf/namespace.d.ts.map +1 -1
- package/ietf/namespace.js +27 -2
- package/ietf/namespace.js.map +1 -1
- package/ietf/object.d.ts +54 -6
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +178 -25
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +35 -10
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +236 -45
- package/ietf/parameters.js.map +1 -1
- package/ietf/priority.d.ts +5 -0
- package/ietf/priority.d.ts.map +1 -0
- package/ietf/priority.js +10 -0
- package/ietf/priority.js.map +1 -0
- package/ietf/properties.d.ts +13 -2
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +65 -8
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +13 -3
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +16 -2
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +25 -6
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +40 -7
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +751 -134
- package/ietf/publisher.js.map +1 -1
- package/ietf/request.js +1 -1
- package/ietf/request.js.map +1 -1
- package/ietf/solicit.d.ts +41 -0
- package/ietf/solicit.d.ts.map +1 -0
- package/ietf/solicit.js +47 -0
- package/ietf/solicit.js.map +1 -0
- package/ietf/subscribe.d.ts +41 -2
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +103 -22
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +21 -7
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +31 -14
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +20 -7
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +512 -142
- package/ietf/subscriber.js.map +1 -1
- package/ietf/version.d.ts +7 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +7 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +10 -5
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +29 -0
- package/internal.d.ts.map +1 -0
- package/internal.js +8 -0
- package/internal.js.map +1 -0
- package/lite/announce.d.ts +62 -15
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +230 -94
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +69 -20
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +128 -81
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +23 -0
- package/lite/datagram.d.ts.map +1 -0
- package/lite/datagram.js +57 -0
- package/lite/datagram.js.map +1 -0
- package/lite/datagram_stream.d.ts +8 -0
- package/lite/datagram_stream.d.ts.map +1 -0
- package/lite/datagram_stream.js +44 -0
- package/lite/datagram_stream.js.map +1 -0
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +2 -2
- package/lite/fetch.js.map +1 -1
- package/lite/index.d.ts +3 -0
- package/lite/index.d.ts.map +1 -1
- package/lite/index.js +3 -0
- package/lite/index.js.map +1 -1
- package/lite/priority.d.ts +67 -0
- package/lite/priority.d.ts.map +1 -0
- package/lite/priority.js +114 -0
- package/lite/priority.js.map +1 -0
- package/lite/probe.d.ts +18 -2
- package/lite/probe.d.ts.map +1 -1
- package/lite/probe.js +24 -22
- package/lite/probe.js.map +1 -1
- package/lite/publisher.d.ts +14 -7
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +406 -84
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +83 -6
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +203 -31
- package/lite/setup.js.map +1 -1
- package/lite/stream.d.ts +12 -3
- package/lite/stream.d.ts.map +1 -1
- package/lite/stream.js +7 -4
- package/lite/stream.js.map +1 -1
- package/lite/subscribe.d.ts +37 -23
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +70 -55
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +29 -23
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +689 -112
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +25 -13
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +60 -35
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +39 -4
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +139 -17
- package/lite/version.js.map +1 -1
- package/mock.d.ts +53 -3
- package/mock.d.ts.map +1 -1
- package/mock.js +110 -21
- package/mock.js.map +1 -1
- package/{lite/origin.d.ts → origin.d.ts} +25 -1
- package/origin.d.ts.map +1 -0
- package/{lite/origin.js → origin.js} +25 -1
- package/origin.js.map +1 -0
- package/package.json +6 -4
- package/path.d.ts +67 -0
- package/path.d.ts.map +1 -1
- package/path.js +128 -0
- package/path.js.map +1 -1
- package/stream.d.ts +94 -4
- package/stream.d.ts.map +1 -1
- package/stream.js +128 -17
- package/stream.js.map +1 -1
- package/time.d.ts +109 -86
- package/time.d.ts.map +1 -1
- package/time.js +80 -30
- package/time.js.map +1 -1
- package/track.d.ts +298 -47
- package/track.d.ts.map +1 -1
- package/track.js +721 -109
- package/track.js.map +1 -1
- package/util/timeout.d.ts +5 -0
- package/util/timeout.d.ts.map +1 -0
- package/util/timeout.js +18 -0
- package/util/timeout.js.map +1 -0
- package/util/utf8.d.ts +3 -0
- package/util/utf8.d.ts.map +1 -0
- package/util/utf8.js +7 -0
- package/util/utf8.js.map +1 -0
- package/varint.d.ts +14 -3
- package/varint.d.ts.map +1 -1
- package/varint.js +23 -12
- package/varint.js.map +1 -1
- package/zod.d.ts +5 -5
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/bandwidth.d.ts +0 -11
- package/bandwidth.d.ts.map +0 -1
- package/bandwidth.js +0 -7
- package/bandwidth.js.map +0 -1
- package/connection/cert.test.d.ts +0 -2
- package/connection/cert.test.d.ts.map +0 -1
- package/connection/cert.test.js +0 -26
- package/connection/cert.test.js.map +0 -1
- package/connection/reload.test.d.ts +0 -2
- package/connection/reload.test.d.ts.map +0 -1
- package/connection/reload.test.js +0 -39
- package/connection/reload.test.js.map +0 -1
- package/group.test.d.ts +0 -2
- package/group.test.d.ts.map +0 -1
- package/group.test.js +0 -66
- package/group.test.js.map +0 -1
- package/ietf/aliases.test.d.ts +0 -2
- package/ietf/aliases.test.d.ts.map +0 -1
- package/ietf/aliases.test.js +0 -30
- package/ietf/aliases.test.js.map +0 -1
- package/ietf/ietf.test.d.ts +0 -2
- package/ietf/ietf.test.d.ts.map +0 -1
- package/ietf/ietf.test.js +0 -873
- package/ietf/ietf.test.js.map +0 -1
- package/integration.test.d.ts +0 -2
- package/integration.test.d.ts.map +0 -1
- package/integration.test.js +0 -90
- package/integration.test.js.map +0 -1
- package/lite/origin.d.ts.map +0 -1
- package/lite/origin.js.map +0 -1
- package/lite/subscriber.test.d.ts +0 -2
- package/lite/subscriber.test.d.ts.map +0 -1
- package/lite/subscriber.test.js +0 -30
- package/lite/subscriber.test.js.map +0 -1
- package/lite/track.test.d.ts +0 -2
- package/lite/track.test.d.ts.map +0 -1
- package/lite/track.test.js +0 -53
- package/lite/track.test.js.map +0 -1
- package/path.test.d.ts +0 -2
- package/path.test.d.ts.map +0 -1
- package/path.test.js +0 -169
- package/path.test.js.map +0 -1
- package/stream.test.d.ts +0 -2
- package/stream.test.d.ts.map +0 -1
- package/stream.test.js +0 -210
- package/stream.test.js.map +0 -1
- package/track.test.d.ts +0 -2
- package/track.test.d.ts.map +0 -1
- package/track.test.js +0 -56
- package/track.test.js.map +0 -1
- package/util/error.d.ts +0 -3
- package/util/error.d.ts.map +0 -1
- package/util/error.js +0 -9
- package/util/error.js.map +0 -1
- package/util/index.d.ts +0 -2
- package/util/index.d.ts.map +0 -1
- package/util/index.js +0 -3
- package/util/index.js.map +0 -1
- package/varint.test.d.ts +0 -2
- package/varint.test.d.ts.map +0 -1
- package/varint.test.js +0 -106
- package/varint.test.js.map +0 -1
package/ietf/publisher.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/* @ts-self-types="./publisher.d.ts" */
|
|
2
|
-
import {
|
|
2
|
+
import { Signal } from "@norskvideo/moq-signals";
|
|
3
|
+
import { error, reason } from "../error.js";
|
|
3
4
|
import * as Path from "../path.js";
|
|
4
5
|
import { Writer } from "../stream.js";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
6
|
+
import { withTimeout } from "../util/timeout.js";
|
|
7
|
+
import * as Cluster from "./cluster.js";
|
|
8
|
+
import { FetchHeader } from "./fetch.js";
|
|
9
|
+
import * as Filter from "./filter.js";
|
|
10
|
+
import { FetchFrame, Frame, Group as GroupMessage } from "./object.js";
|
|
11
|
+
import { fromWire } from "./priority.js";
|
|
12
|
+
import * as Properties from "./properties.js";
|
|
7
13
|
import { PublishDone } from "./publish.js";
|
|
8
14
|
import { PublishNamespace, PublishNamespaceDone, PublishNamespaceOk } from "./publish_namespace.js";
|
|
9
15
|
import { RequestError, RequestOk } from "./request.js";
|
|
@@ -11,6 +17,25 @@ import { SubscribeError, SubscribeOk } from "./subscribe.js";
|
|
|
11
17
|
import { SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, SubscribeNamespaceOk, } from "./subscribe_namespace.js";
|
|
12
18
|
import { TrackStatus } from "./track.js";
|
|
13
19
|
import { Version } from "./version.js";
|
|
20
|
+
/** First wait before re-offering a namespace the peer refused or we couldn't open for. */
|
|
21
|
+
const RETRY_BASE = 100;
|
|
22
|
+
/** Ceiling on that wait. The loop retries for the life of the session, so it must not spin. */
|
|
23
|
+
const RETRY_MAX = 5000;
|
|
24
|
+
/** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */
|
|
25
|
+
const PUBLISH_DONE_STATUS = {
|
|
26
|
+
INTERNAL_ERROR: 0x0,
|
|
27
|
+
TRACK_ENDED: 0x2,
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* How long one advertisement may take to be answered. Matches the Rust publisher, and the
|
|
31
|
+
* peer accepting the stream is only half the exchange: one it never answers on holds the
|
|
32
|
+
* loop just as effectively as one it never grants.
|
|
33
|
+
*/
|
|
34
|
+
const ADVERTISE_TIMEOUT_MS = 5000;
|
|
35
|
+
/** Sleep `delay`, jittered, so a relay's namespaces don't all retry on the same tick. */
|
|
36
|
+
function retryAfter(delay) {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, delay * (0.5 + Math.random() / 2)));
|
|
38
|
+
}
|
|
14
39
|
/**
|
|
15
40
|
* Handles publishing broadcasts using moq-transport protocol.
|
|
16
41
|
* Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16).
|
|
@@ -20,86 +45,44 @@ import { Version } from "./version.js";
|
|
|
20
45
|
export class Publisher {
|
|
21
46
|
#quic;
|
|
22
47
|
#session;
|
|
48
|
+
#requiresSolicitation;
|
|
49
|
+
// What every advertisement carries on a session that negotiated the MoQ Cluster
|
|
50
|
+
// extension: a hop path holding our own id, so the peer can tell that what it hears
|
|
51
|
+
// back came from us. `undefined` when nothing negotiated it.
|
|
52
|
+
#advert;
|
|
23
53
|
// Our published broadcasts.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#announcedConsumers = new Set();
|
|
54
|
+
// It's a signal so we can live update any subscribe_namespace streams.
|
|
55
|
+
#broadcasts = new Signal(new Map());
|
|
27
56
|
/**
|
|
28
57
|
* Creates a new Publisher instance.
|
|
29
|
-
* @param quic - The WebTransport session (for uni streams)
|
|
30
|
-
* @param session - The session abstraction for bidi streams and request IDs
|
|
31
58
|
*
|
|
32
59
|
* @internal
|
|
33
60
|
*/
|
|
34
|
-
constructor(quic, session) {
|
|
61
|
+
constructor({ quic, session, requiresSolicitation, cluster, }) {
|
|
35
62
|
this.#quic = quic;
|
|
36
63
|
this.#session = session;
|
|
64
|
+
this.#requiresSolicitation = requiresSolicitation;
|
|
65
|
+
this.#advert = Cluster.advertise(cluster);
|
|
37
66
|
}
|
|
38
67
|
/**
|
|
39
68
|
* Publishes a broadcast with any associated tracks.
|
|
40
|
-
*
|
|
69
|
+
* The namespace is advertised with an unsolicited PUBLISH_NAMESPACE, or on request
|
|
70
|
+
* if the peer asked for that (see {@link runPublishNamespaces}).
|
|
41
71
|
*/
|
|
42
72
|
publish(path, broadcast) {
|
|
43
|
-
this.#broadcasts.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
// Write PublishNamespace
|
|
55
|
-
await stream.writer.u53(PublishNamespace.id);
|
|
56
|
-
const msg = new PublishNamespace({ requestId, trackNamespace: path });
|
|
57
|
-
await msg.encode(stream.writer, this.#session.version);
|
|
58
|
-
// Read response (RequestOk and PublishNamespaceOk share 0x07)
|
|
59
|
-
const respTypeId = await stream.reader.u53();
|
|
60
|
-
if (respTypeId === RequestOk.id) {
|
|
61
|
-
// Draft-14 sends PublishNamespaceOk (requestId only, no parameters)
|
|
62
|
-
if (this.#session.version === Version.DRAFT_14) {
|
|
63
|
-
await PublishNamespaceOk.decode(stream.reader, this.#session.version);
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
await RequestOk.decode(stream.reader, this.#session.version);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
throw new Error(`PublishNamespace rejected: typeId=0x${respTypeId.toString(16)}`);
|
|
71
|
-
}
|
|
72
|
-
// Wait for broadcast to close or stream to close (peer cancelled)
|
|
73
|
-
await Promise.race([broadcast.closed, stream.reader.closed]);
|
|
74
|
-
// For v14-v16: send explicit PublishNamespaceDone (removed in v17+)
|
|
75
|
-
if (this.#session.version === Version.DRAFT_14 ||
|
|
76
|
-
this.#session.version === Version.DRAFT_15 ||
|
|
77
|
-
this.#session.version === Version.DRAFT_16) {
|
|
78
|
-
try {
|
|
79
|
-
await stream.writer.u53(PublishNamespaceDone.id);
|
|
80
|
-
const done = new PublishNamespaceDone({ trackNamespace: path, requestId });
|
|
81
|
-
await done.encode(stream.writer, this.#session.version);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// Stream might already be closed
|
|
85
|
-
}
|
|
73
|
+
this.#broadcasts.mutate((broadcasts) => {
|
|
74
|
+
if (!broadcasts)
|
|
75
|
+
throw new Error("closed");
|
|
76
|
+
broadcasts.set(path, broadcast);
|
|
77
|
+
});
|
|
78
|
+
// Remove the broadcast from the lookup when it's closed, unless the path was republished.
|
|
79
|
+
void broadcast.closed.then(() => {
|
|
80
|
+
this.#broadcasts.mutate((broadcasts) => {
|
|
81
|
+
if (broadcasts?.get(path) === broadcast) {
|
|
82
|
+
broadcasts.delete(path);
|
|
86
83
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
catch (err) {
|
|
90
|
-
stream.abort(error(err));
|
|
91
|
-
throw err;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
const e = error(err);
|
|
96
|
-
console.warn(`announce failed: broadcast=${path} error=${e.message}`);
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
broadcast.close();
|
|
100
|
-
this.#broadcasts.delete(path);
|
|
101
|
-
this.#notifyConsumers(path, false);
|
|
102
|
-
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
103
86
|
}
|
|
104
87
|
/**
|
|
105
88
|
* Handles an incoming SUBSCRIBE request on a bidi stream.
|
|
@@ -110,7 +93,7 @@ export class Publisher {
|
|
|
110
93
|
async runSubscribe(msg, stream) {
|
|
111
94
|
const version = this.#session.version;
|
|
112
95
|
const name = msg.trackNamespace;
|
|
113
|
-
const broadcast = this.#broadcasts.get(name);
|
|
96
|
+
const broadcast = this.#broadcasts.peek()?.get(name);
|
|
114
97
|
if (!broadcast) {
|
|
115
98
|
// Write error response
|
|
116
99
|
if (version === Version.DRAFT_14) {
|
|
@@ -134,8 +117,37 @@ export class Publisher {
|
|
|
134
117
|
stream.close();
|
|
135
118
|
return;
|
|
136
119
|
}
|
|
137
|
-
const
|
|
120
|
+
const priority = fromWire(msg.subscriberPriority);
|
|
121
|
+
const track = broadcast.subscribe(msg.trackName, { priority });
|
|
122
|
+
// Hoisted so the cleanup below reaches it: the fork happens before SUBSCRIBE_OK is
|
|
123
|
+
// written, and a write that throws must not strand the sink it attached.
|
|
124
|
+
let cache;
|
|
138
125
|
try {
|
|
126
|
+
const timescale = (await track.info()).timescale;
|
|
127
|
+
// The filter and any fill are relative to the live edge, so snapshot it once: the
|
|
128
|
+
// fill ends exactly where a Next Object subscription begins, which is what lets
|
|
129
|
+
// the draft's current-group join (Next Object plus a StartGroup=1 fill) cover the
|
|
130
|
+
// group with no gap and no overlap.
|
|
131
|
+
const edge = liveEdge(track);
|
|
132
|
+
const range = subscribeRange(msg, edge, version);
|
|
133
|
+
// The wire request tells an upstream what we need; the cursor is what actually
|
|
134
|
+
// trims this subscriber, since the producer fans every cached group out to every
|
|
135
|
+
// sink regardless. An absent start joins at the latest group, which is what
|
|
136
|
+
// moq-lite means by joining a live track.
|
|
137
|
+
track.update({
|
|
138
|
+
priority,
|
|
139
|
+
startGroup: range.start && Number(range.start.group),
|
|
140
|
+
endGroup: range.end && Number(range.end.group),
|
|
141
|
+
});
|
|
142
|
+
const startGroup = range.start ? Number(range.start.group) : track.latest();
|
|
143
|
+
if (startGroup !== undefined)
|
|
144
|
+
track.startAt(startGroup);
|
|
145
|
+
// A fill reads the group cache through its own consumer, independent of the
|
|
146
|
+
// subscription's cursor. Forked from this subscriber rather than resolved through
|
|
147
|
+
// the broadcast again: a dynamic serve is one request per peer subscription, so
|
|
148
|
+
// asking the broadcast would mint a second producer nobody has accepted.
|
|
149
|
+
const fill = msg.fill && Filter.isDraft20(version) ? fillRange(msg.fill, msg.filter, edge.largest) : undefined;
|
|
150
|
+
cache = fill && fill.kind !== "empty" ? track.fork({ priority }) : undefined;
|
|
139
151
|
// Send SUBSCRIBE_OK
|
|
140
152
|
await stream.writer.u53(SubscribeOk.id);
|
|
141
153
|
const ok = new SubscribeOk({
|
|
@@ -143,73 +155,183 @@ export class Publisher {
|
|
|
143
155
|
? msg.requestId
|
|
144
156
|
: undefined,
|
|
145
157
|
trackAlias: msg.requestId,
|
|
158
|
+
// Required once the track has content; a fill-requesting subscriber sizes its
|
|
159
|
+
// backfill against this.
|
|
160
|
+
largest: edge.largest && { groupId: edge.largest.group, objectId: edge.largest.object },
|
|
161
|
+
properties: msg.propertiesWanted
|
|
162
|
+
? // Declaring the timescale is what opts the track into timestamps; every
|
|
163
|
+
// object Timestamp below is in these units. We serve the newest group
|
|
164
|
+
// first, matching moq-lite.
|
|
165
|
+
{ timescale, groupOrder: Properties.DESCENDING }
|
|
166
|
+
: // INCLUDE_PROPERTIES=0. The block stays present but empty, which also means
|
|
167
|
+
// the track opts out of timestamps for this subscriber.
|
|
168
|
+
{},
|
|
146
169
|
});
|
|
147
170
|
await ok.encode(stream.writer, version);
|
|
148
171
|
console.debug(`publish ok: broadcast=${name} track=${track.name}`);
|
|
172
|
+
// Cancels groups still queued for a stream slot. Only the subscriber leaving counts:
|
|
173
|
+
// a track that ran out of groups still has to flush the ones already queued, and we
|
|
174
|
+
// close the stream ourselves below to say so.
|
|
175
|
+
let finished = false;
|
|
176
|
+
let unsubscribe;
|
|
177
|
+
const unsubscribed = new Promise((resolve) => {
|
|
178
|
+
unsubscribe = resolve;
|
|
179
|
+
});
|
|
180
|
+
void stream.reader.closed.then(() => {
|
|
181
|
+
if (!finished)
|
|
182
|
+
unsubscribe();
|
|
183
|
+
},
|
|
184
|
+
// A reset is always the peer.
|
|
185
|
+
() => unsubscribe());
|
|
149
186
|
// Serve track groups, racing with stream close (= Unsubscribe)
|
|
150
187
|
const serving = (async () => {
|
|
151
188
|
for (;;) {
|
|
152
189
|
const group = await track.recvGroup();
|
|
153
190
|
if (!group)
|
|
154
191
|
return;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
192
|
+
// Past the filter's end. Dropped here rather than through `endAt`, which
|
|
193
|
+
// parks a capped group instead: this range is fixed for the life of the
|
|
194
|
+
// subscription, so a group above it is never coming back in, and holding
|
|
195
|
+
// one keeps the loop from ever ending. A producer that publishes beyond
|
|
196
|
+
// the end and then closes would otherwise strand PUBLISH_DONE.
|
|
197
|
+
if (range.end !== undefined && BigInt(group.sequence) > range.end.group) {
|
|
198
|
+
group.close();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
void this.#runGroup({
|
|
165
202
|
requestId: msg.requestId,
|
|
166
|
-
|
|
167
|
-
|
|
203
|
+
group,
|
|
204
|
+
timescale,
|
|
205
|
+
stamped: msg.propertiesWanted,
|
|
206
|
+
slice: groupSlice(range, group.sequence),
|
|
207
|
+
unsubscribed,
|
|
168
208
|
});
|
|
169
|
-
await done.encode(stream.writer, version);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
// Stream might already be closed by peer
|
|
173
209
|
}
|
|
210
|
+
})();
|
|
211
|
+
// The fill (when one was requested) runs alongside on its own fetch stream; its
|
|
212
|
+
// failures reset that stream and never touch the subscription.
|
|
213
|
+
const filling = fill && cache
|
|
214
|
+
? this.#runFill({
|
|
215
|
+
requestId: msg.requestId,
|
|
216
|
+
priority,
|
|
217
|
+
fill,
|
|
218
|
+
cache,
|
|
219
|
+
timescale,
|
|
220
|
+
stamped: msg.propertiesWanted,
|
|
221
|
+
unsubscribed,
|
|
222
|
+
})
|
|
223
|
+
: Promise.resolve();
|
|
224
|
+
let publishError;
|
|
225
|
+
try {
|
|
226
|
+
await Promise.race([Promise.all([serving, filling]), stream.reader.closed]);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
publishError = error(err);
|
|
230
|
+
}
|
|
231
|
+
console.debug(`publish done: broadcast=${name} track=${track.name}`);
|
|
232
|
+
if (publishError) {
|
|
233
|
+
console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(publishError)}`);
|
|
234
|
+
}
|
|
235
|
+
// PUBLISH_DONE is required for every supported draft. The peer may have already
|
|
236
|
+
// closed its side to unsubscribe, in which case there is nowhere to send it.
|
|
237
|
+
try {
|
|
238
|
+
await stream.writer.u53(PublishDone.id);
|
|
239
|
+
const done = new PublishDone({
|
|
240
|
+
requestId: version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16
|
|
241
|
+
? msg.requestId
|
|
242
|
+
: undefined,
|
|
243
|
+
statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED,
|
|
244
|
+
reasonPhrase: publishError ? "internal error" : "track ended",
|
|
245
|
+
});
|
|
246
|
+
await done.encode(stream.writer, version);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Stream might already be closed by peer.
|
|
174
250
|
}
|
|
251
|
+
// Only now is the close below ours. Claiming it any earlier would read a peer FIN
|
|
252
|
+
// that lands while PublishDone is still going out as our own completion, leaving
|
|
253
|
+
// queued groups to open for a subscriber that has already left.
|
|
254
|
+
finished = true;
|
|
175
255
|
stream.close();
|
|
176
256
|
}
|
|
177
257
|
catch (err) {
|
|
178
258
|
const e = error(err);
|
|
179
|
-
console.warn(`publish error: broadcast=${name} track=${track.name} error=${e
|
|
259
|
+
console.warn(`publish error: broadcast=${name} track=${track.name} error=${reason(e)}`);
|
|
180
260
|
stream.abort(e);
|
|
181
261
|
}
|
|
182
262
|
finally {
|
|
183
263
|
track.close();
|
|
264
|
+
// Idempotent, and the backstop for a throw between the fork and the fill starting:
|
|
265
|
+
// #runFill closes it itself on every path it reaches.
|
|
266
|
+
cache?.close();
|
|
184
267
|
}
|
|
185
268
|
}
|
|
186
269
|
/**
|
|
187
270
|
* Runs a group and sends its frames using ObjectStream (Subgroup delivery mode).
|
|
188
271
|
*/
|
|
189
|
-
async #runGroup(
|
|
272
|
+
async #runGroup(options) {
|
|
273
|
+
const { requestId, group, timescale, stamped, slice, unsubscribed } = options;
|
|
190
274
|
try {
|
|
191
|
-
|
|
275
|
+
// One stream per group is faster than a peer at its limit can retire them, so this
|
|
276
|
+
// is the one path that doesn't wait for a slot: the transport would serve the opens
|
|
277
|
+
// in the order we asked, which is oldest-first, exactly backwards for live media.
|
|
278
|
+
// Failing here drops the group and lets the next one compete for the next slot.
|
|
279
|
+
const stream = await Writer.tryOpen(this.#quic, {
|
|
280
|
+
cancel: unsubscribed,
|
|
281
|
+
version: this.#session.version,
|
|
282
|
+
waitUntilAvailable: false,
|
|
283
|
+
});
|
|
284
|
+
if (!stream) {
|
|
285
|
+
group.close(new Error("no stream slot"));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
192
288
|
const header = new GroupMessage({
|
|
193
289
|
trackAlias: requestId,
|
|
194
290
|
groupId: group.sequence,
|
|
195
291
|
subGroupId: 0,
|
|
196
292
|
publisherPriority: 0,
|
|
197
293
|
flags: {
|
|
198
|
-
|
|
294
|
+
// The object properties carry the timestamp, so there is nothing to write
|
|
295
|
+
// when the track declared no units to read one in.
|
|
296
|
+
hasExtensions: stamped,
|
|
199
297
|
hasSubgroup: false,
|
|
200
298
|
hasSubgroupObject: false,
|
|
201
299
|
hasEnd: true,
|
|
202
300
|
hasPriority: true,
|
|
301
|
+
// Only honest when the stream really starts at the group's first object;
|
|
302
|
+
// a trimmed head starts partway through.
|
|
303
|
+
firstObject: slice.skip === 0,
|
|
203
304
|
},
|
|
204
305
|
});
|
|
205
306
|
await header.encode(stream, this.#session.version);
|
|
206
307
|
try {
|
|
308
|
+
// The first written object goes on the wire as its absolute id, so a trimmed
|
|
309
|
+
// head shows the true numbering rather than a silently renumbered group.
|
|
310
|
+
let first = true;
|
|
311
|
+
// The next object id that could be written, which starts at the filter rather
|
|
312
|
+
// than at zero. A backwards range (`startObject` above `endObject` in the same
|
|
313
|
+
// group) is empty, and starting here is what lets the check below see that
|
|
314
|
+
// before the read parks on an object the range already excludes.
|
|
315
|
+
let next = slice.skip;
|
|
207
316
|
for (;;) {
|
|
208
|
-
|
|
317
|
+
// The filter ends inside this group: everything at `until` and beyond is
|
|
318
|
+
// outside the requested range, so stop without waiting for the group's end.
|
|
319
|
+
if (slice.until !== undefined && next >= slice.until)
|
|
320
|
+
break;
|
|
321
|
+
// Reading from the filter's start drops the objects below it, including any the
|
|
322
|
+
// group's cache evicted: they are outside the requested range, so losing them is
|
|
323
|
+
// not the gap that would otherwise reset this stream and forfeit the rest of the
|
|
324
|
+
// group. An eviction at or above the start is a real gap and still throws.
|
|
325
|
+
const frame = await Promise.race([group.readFrameSequence({ from: slice.skip }), stream.closed]);
|
|
209
326
|
if (!frame)
|
|
210
327
|
break;
|
|
211
|
-
|
|
212
|
-
|
|
328
|
+
next = frame.sequence + 1;
|
|
329
|
+
if (slice.until !== undefined && frame.sequence >= slice.until)
|
|
330
|
+
break;
|
|
331
|
+
const obj = new Frame({ payload: frame.payload, timestamp: frame.timestamp });
|
|
332
|
+
const delta = first ? frame.sequence : 0;
|
|
333
|
+
first = false;
|
|
334
|
+
await obj.encode(stream, header.flags, timescale, this.#session.version, delta);
|
|
213
335
|
}
|
|
214
336
|
stream.close();
|
|
215
337
|
}
|
|
@@ -221,15 +343,112 @@ export class Publisher {
|
|
|
221
343
|
group.close();
|
|
222
344
|
}
|
|
223
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Serve a draft-20 fill on its own fetch stream: the requested range, read from the
|
|
348
|
+
* group cache, capped at the Largest Object snapshot.
|
|
349
|
+
*
|
|
350
|
+
* A fill is a promise once requested. An empty range opens no stream, but a range we
|
|
351
|
+
* cannot serve still opens one and resets it right after the FETCH_HEADER, the draft's
|
|
352
|
+
* fill-failure signal. Nothing here touches the subscription either way.
|
|
353
|
+
*/
|
|
354
|
+
async #runFill(options) {
|
|
355
|
+
const { requestId, fill, cache, timescale, stamped, unsubscribed } = options;
|
|
356
|
+
const version = this.#session.version;
|
|
357
|
+
// Everything is inside the try so the cache fork is released on every path out,
|
|
358
|
+
// including a transport failure while opening the stream.
|
|
359
|
+
let stream;
|
|
360
|
+
try {
|
|
361
|
+
if (fill.kind === "empty")
|
|
362
|
+
return;
|
|
363
|
+
stream = await Writer.tryOpen(this.#quic, { cancel: unsubscribed, version });
|
|
364
|
+
if (!stream) {
|
|
365
|
+
console.debug(`fill stream failed to open: fill=${requestId}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
await stream.u53(FetchHeader.type);
|
|
369
|
+
await new FetchHeader({ requestId }).encode(stream, version);
|
|
370
|
+
if (fill.kind !== "group") {
|
|
371
|
+
throw new Error("a fill spanning several groups is not supported");
|
|
372
|
+
}
|
|
373
|
+
const group = takeGroup(cache, Number(fill.sequence));
|
|
374
|
+
try {
|
|
375
|
+
await this.#writeFillGroup(stream, group, fill, timescale, stamped, unsubscribed);
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
group.close();
|
|
379
|
+
}
|
|
380
|
+
stream.close();
|
|
381
|
+
console.debug(`fill complete: fill=${requestId}`);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
// A fill never fails the subscription; its own stream carries the failure.
|
|
385
|
+
const e = error(err);
|
|
386
|
+
console.debug(`fill failed, resetting its stream: fill=${requestId} error=${reason(e)}`);
|
|
387
|
+
stream?.reset(e);
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
cache.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Write one cached group's frames as draft-20 fetch objects.
|
|
395
|
+
*
|
|
396
|
+
* The cap is the Largest Object snapshot: the group may keep growing, but everything
|
|
397
|
+
* past the snapshot belongs to the subscription, not the fill.
|
|
398
|
+
*/
|
|
399
|
+
async #writeFillGroup(stream, group, fill, timescale, stamped, unsubscribed) {
|
|
400
|
+
let first = true;
|
|
401
|
+
let next = 0n;
|
|
402
|
+
// The subscriber leaving has to end the fill too. An absolute filter ending below the
|
|
403
|
+
// edge with no end object leaves `until` unset, so this reads until the group closes,
|
|
404
|
+
// which a still-open past group may never do. Watching only the fetch stream would
|
|
405
|
+
// then pin the group and the cache subscription behind it for the life of the track.
|
|
406
|
+
let left = false;
|
|
407
|
+
const cancelled = unsubscribed.then(() => {
|
|
408
|
+
left = true;
|
|
409
|
+
return undefined;
|
|
410
|
+
});
|
|
411
|
+
for (;;) {
|
|
412
|
+
// The group may keep growing, but everything at `until` and beyond belongs to the
|
|
413
|
+
// subscription, so stop without waiting for the group's end.
|
|
414
|
+
if (fill.until !== undefined && next >= fill.until)
|
|
415
|
+
break;
|
|
416
|
+
// Reading from the fill's start drops everything below it, evicted objects included;
|
|
417
|
+
// see the same read in #runGroup.
|
|
418
|
+
const frame = await Promise.race([
|
|
419
|
+
group.readFrameSequence({ from: Number(fill.skip) }),
|
|
420
|
+
stream.closed,
|
|
421
|
+
cancelled,
|
|
422
|
+
]);
|
|
423
|
+
if (left)
|
|
424
|
+
throw new Error("unsubscribed before the fill finished");
|
|
425
|
+
if (!frame)
|
|
426
|
+
break;
|
|
427
|
+
next = BigInt(frame.sequence) + 1n;
|
|
428
|
+
if (fill.until !== undefined && BigInt(frame.sequence) >= fill.until)
|
|
429
|
+
break;
|
|
430
|
+
const obj = new FetchFrame({ payload: frame.payload, timestamp: stamped ? frame.timestamp : undefined });
|
|
431
|
+
await obj.encode(stream, { group: Number(fill.sequence), object: frame.sequence, first }, timescale, this.#session.version);
|
|
432
|
+
first = false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
224
435
|
/**
|
|
225
436
|
* Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream.
|
|
226
|
-
*
|
|
437
|
+
*
|
|
438
|
+
* This carries the advertisements only when the peer asked to be told on request
|
|
439
|
+
* (MoQ Solicit); otherwise {@link runPublishNamespaces} has already announced
|
|
440
|
+
* everything and repeating it here would leave the peer holding two sources for one
|
|
441
|
+
* broadcast. Draft-16+ streams Namespace entries inline; draft-14/15 predate those
|
|
442
|
+
* messages, so each advertisement is a PUBLISH_NAMESPACE request of its own.
|
|
227
443
|
*
|
|
228
444
|
* @internal
|
|
229
445
|
*/
|
|
230
446
|
async runSubscribeNamespace(msg, stream) {
|
|
231
447
|
const version = this.#session.version;
|
|
232
448
|
const prefix = msg.namespace;
|
|
449
|
+
const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15;
|
|
450
|
+
// Draft-14/15: the open PUBLISH_NAMESPACE request per advertised suffix.
|
|
451
|
+
const requests = new Map();
|
|
233
452
|
try {
|
|
234
453
|
// Send OK response
|
|
235
454
|
if (version === Version.DRAFT_14) {
|
|
@@ -244,45 +463,298 @@ export class Publisher {
|
|
|
244
463
|
});
|
|
245
464
|
await ok.encode(stream.writer, version);
|
|
246
465
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
continue;
|
|
253
|
-
announced.append({ path: suffix, active: true });
|
|
466
|
+
if (!this.#requiresSolicitation) {
|
|
467
|
+
// Already announced, unasked. Hold the stream open until the peer is done.
|
|
468
|
+
await stream.reader.closed;
|
|
469
|
+
stream.close();
|
|
470
|
+
return;
|
|
254
471
|
}
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
472
|
+
// Reports whether the peer now holds the namespace: an inline entry always
|
|
473
|
+
// lands, but a PUBLISH_NAMESPACE request can be declined.
|
|
474
|
+
const advertise = async (suffix) => {
|
|
475
|
+
if (legacy) {
|
|
476
|
+
return await this.#advertise(Path.join(prefix, suffix), requests, refused);
|
|
477
|
+
}
|
|
478
|
+
await stream.writer.u53(SubscribeNamespaceEntry.id);
|
|
479
|
+
await new SubscribeNamespaceEntry({ suffix, cluster: this.#advert }).encode(stream.writer, version);
|
|
480
|
+
return true;
|
|
481
|
+
};
|
|
482
|
+
const withdraw = async (suffix) => {
|
|
483
|
+
if (legacy) {
|
|
484
|
+
await this.#withdraw(Path.join(prefix, suffix), requests);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
await stream.writer.u53(SubscribeNamespaceEntryDone.id);
|
|
488
|
+
await new SubscribeNamespaceEntryDone({ suffix }).encode(stream.writer, version);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
let active = new Set();
|
|
492
|
+
let retry = 0;
|
|
493
|
+
// What the peer refused, and whether coming back is worth anything.
|
|
494
|
+
const refused = new Map();
|
|
495
|
+
for (;;) {
|
|
496
|
+
// Subscribe BEFORE reconciling, for the same reason as
|
|
497
|
+
// {@link runPublishNamespaces}: a publish landing while an advertisement
|
|
498
|
+
// waits for its reply only notifies listeners already registered.
|
|
499
|
+
// TODO Make a better helper within Signals.
|
|
500
|
+
let dispose;
|
|
501
|
+
const changed = new Promise((resolve) => {
|
|
502
|
+
dispose = this.#broadcasts.changed(resolve);
|
|
503
|
+
});
|
|
504
|
+
const broadcasts = this.#broadcasts.peek();
|
|
505
|
+
if (!broadcasts) {
|
|
506
|
+
dispose();
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const updated = new Set();
|
|
510
|
+
for (const name of broadcasts.keys()) {
|
|
511
|
+
const suffix = Path.stripPrefix(prefix, name);
|
|
512
|
+
if (suffix === null)
|
|
513
|
+
continue;
|
|
514
|
+
updated.add(suffix);
|
|
515
|
+
}
|
|
516
|
+
// A namespace that is gone takes its refusal with it, so re-announcing the
|
|
517
|
+
// path offers it again.
|
|
518
|
+
const live = new Set([...updated].map((suffix) => Path.join(prefix, suffix)));
|
|
519
|
+
for (const path of [...refused.keys()]) {
|
|
520
|
+
if (!live.has(path))
|
|
521
|
+
refused.delete(path);
|
|
522
|
+
}
|
|
523
|
+
// Track what the peer holds rather than what we attempted: a declined
|
|
524
|
+
// advertisement stays out of `held`, so the next turn retries it instead of
|
|
525
|
+
// believing the namespace is already up.
|
|
526
|
+
const held = new Set(active);
|
|
527
|
+
for (const added of updated.difference(active)) {
|
|
528
|
+
if (this.#offerable(Path.join(prefix, added), refused)) {
|
|
529
|
+
if (await advertise(added))
|
|
530
|
+
held.add(added);
|
|
272
531
|
}
|
|
273
532
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
533
|
+
for (const removed of active.difference(updated)) {
|
|
534
|
+
await withdraw(removed);
|
|
535
|
+
held.delete(removed);
|
|
536
|
+
}
|
|
537
|
+
active = held;
|
|
538
|
+
// Whatever we wanted up and could not get up, as {@link runPublishNamespaces}
|
|
539
|
+
// does: only a legacy request can be declined, and nothing about the peer
|
|
540
|
+
// starting to answer raises a signal this loop is watching.
|
|
541
|
+
const outstanding = [...updated.difference(active)].some((suffix) => this.#pending(Path.join(prefix, suffix), refused));
|
|
542
|
+
retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
|
|
543
|
+
// Wait for the next change, or for the peer to unsubscribe.
|
|
544
|
+
const next = await (retry
|
|
545
|
+
? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => broadcasts)])
|
|
546
|
+
: Promise.race([changed, stream.reader.closed]));
|
|
547
|
+
dispose();
|
|
548
|
+
if (!next)
|
|
549
|
+
break;
|
|
278
550
|
}
|
|
279
551
|
stream.close();
|
|
280
552
|
}
|
|
281
553
|
catch (err) {
|
|
282
554
|
const e = error(err);
|
|
283
|
-
console.debug(`subscribe_namespace stream error: ${e
|
|
555
|
+
console.debug(`subscribe_namespace stream error: ${reason(e)}`);
|
|
284
556
|
stream.abort(e);
|
|
285
557
|
}
|
|
558
|
+
finally {
|
|
559
|
+
// This subscription's advertisements die with it.
|
|
560
|
+
for (const path of [...requests.keys()]) {
|
|
561
|
+
await this.#withdraw(path, requests);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Advertise every published broadcast with an unsolicited PUBLISH_NAMESPACE, until
|
|
567
|
+
* the publisher is closed.
|
|
568
|
+
*
|
|
569
|
+
* The peers that never send SUBSCRIBE_NAMESPACE are exactly the ones expecting a
|
|
570
|
+
* publisher to announce itself, so announcing is the default. A peer that would
|
|
571
|
+
* rather ask says so in its SETUP (MoQ Solicit) and this does nothing, leaving
|
|
572
|
+
* {@link runSubscribeNamespace} to carry the advertisements instead. Exactly one of
|
|
573
|
+
* the two is live, so the peer never hears a namespace twice.
|
|
574
|
+
*
|
|
575
|
+
* @internal
|
|
576
|
+
*/
|
|
577
|
+
async runPublishNamespaces() {
|
|
578
|
+
if (this.#requiresSolicitation) {
|
|
579
|
+
// The peer asked to be told on request; runSubscribeNamespace answers it.
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// The open PUBLISH_NAMESPACE request per advertised path.
|
|
583
|
+
const requests = new Map();
|
|
584
|
+
try {
|
|
585
|
+
let active = new Set();
|
|
586
|
+
let retry = 0;
|
|
587
|
+
// What the peer refused, and whether coming back is worth anything.
|
|
588
|
+
const refused = new Map();
|
|
589
|
+
for (;;) {
|
|
590
|
+
// Subscribe BEFORE reconciling. Each advertisement below waits a round trip
|
|
591
|
+
// for the peer's reply, and a publish that lands in that window notifies
|
|
592
|
+
// only the listeners already registered; one created afterwards would sleep
|
|
593
|
+
// through it and leave the namespace unadvertised until something unrelated
|
|
594
|
+
// changed.
|
|
595
|
+
// TODO Make a better helper within Signals.
|
|
596
|
+
let dispose;
|
|
597
|
+
const changed = new Promise((resolve) => {
|
|
598
|
+
dispose = this.#broadcasts.changed(resolve);
|
|
599
|
+
});
|
|
600
|
+
const broadcasts = this.#broadcasts.peek();
|
|
601
|
+
if (!broadcasts) {
|
|
602
|
+
dispose();
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
const updated = new Set(broadcasts.keys());
|
|
606
|
+
// A namespace that is gone takes its refusal with it, so re-announcing the
|
|
607
|
+
// path offers it again. Rust gets this by rebuilding the watched entry.
|
|
608
|
+
for (const path of [...refused.keys()]) {
|
|
609
|
+
if (!updated.has(path))
|
|
610
|
+
refused.delete(path);
|
|
611
|
+
}
|
|
612
|
+
for (const added of updated.difference(active)) {
|
|
613
|
+
if (this.#offerable(added, refused)) {
|
|
614
|
+
await this.#advertise(added, requests, refused);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const removed of active.difference(updated)) {
|
|
618
|
+
await this.#withdraw(removed, requests);
|
|
619
|
+
}
|
|
620
|
+
// What the peer holds, not what we attempted: a declined PUBLISH_NAMESPACE
|
|
621
|
+
// leaves no request behind, so it stays outstanding below.
|
|
622
|
+
active = new Set(requests.keys());
|
|
623
|
+
// Whatever we wanted up and could not get up. Stream credit freeing, a
|
|
624
|
+
// transient failure clearing, or the peer starting to answer raises no
|
|
625
|
+
// signal of its own, so the only way back is to ask again on a timer.
|
|
626
|
+
const outstanding = [...updated.difference(active)].some((path) => this.#pending(path, refused));
|
|
627
|
+
retry = outstanding ? Math.min(retry ? retry * 2 : RETRY_BASE, RETRY_MAX) : 0;
|
|
628
|
+
// Wait for the next change, which has already fired if one landed above.
|
|
629
|
+
const next = await (retry
|
|
630
|
+
? Promise.race([changed, retryAfter(retry).then(() => broadcasts)])
|
|
631
|
+
: changed);
|
|
632
|
+
dispose();
|
|
633
|
+
if (!next)
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
// Nothing restarts this loop, so whatever got us here cost the session its
|
|
639
|
+
// discovery. Not a debug-level event.
|
|
640
|
+
console.warn(`publish_namespace loop failed: ${reason(error(err))}`);
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
// Close out every open PUBLISH_NAMESPACE request.
|
|
644
|
+
for (const path of [...requests.keys()]) {
|
|
645
|
+
await this.#withdraw(path, requests);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Whether a namespace may be offered to the peer right now.
|
|
651
|
+
*
|
|
652
|
+
* A peer that asked never to be offered it again means it, whatever brought us back;
|
|
653
|
+
* one that named a minimum wait gets it, even when our own backoff comes round sooner.
|
|
654
|
+
*/
|
|
655
|
+
#offerable(path, refused) {
|
|
656
|
+
const entry = refused.get(path);
|
|
657
|
+
if (entry === undefined)
|
|
658
|
+
return true;
|
|
659
|
+
return entry !== "never" && Date.now() >= entry;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Whether the loop should keep coming back to a namespace the peer does not hold.
|
|
663
|
+
*
|
|
664
|
+
* Distinct from {@link offerable}, and the difference is what arms the retry: a
|
|
665
|
+
* namespace waiting out a minimum is not offerable yet but is still pending, and
|
|
666
|
+
* gating the timer on offerable instead would disarm it for exactly the wait it is
|
|
667
|
+
* supposed to be counting. Only a refusal that forbids retrying ends it.
|
|
668
|
+
*/
|
|
669
|
+
#pending(path, refused) {
|
|
670
|
+
return refused.get(path) !== "never";
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Advertise one namespace on its own PUBLISH_NAMESPACE request. A declined request
|
|
674
|
+
* is logged and skipped: a peer that wants none of this rejects each one and stays
|
|
675
|
+
* connected.
|
|
676
|
+
*
|
|
677
|
+
* `refused` records what a refusal said about coming back, so a peer that asked not to
|
|
678
|
+
* be offered a namespace again is not re-offered it by the retry above.
|
|
679
|
+
*/
|
|
680
|
+
async #advertise(path, requests, refused) {
|
|
681
|
+
const requestId = await this.#session.nextRequestId();
|
|
682
|
+
if (requestId === undefined)
|
|
683
|
+
return false;
|
|
684
|
+
// Opening is inside the try because it fails like any other advertisement does:
|
|
685
|
+
// a peer out of stream credit rejects here, and letting that escape would unwind
|
|
686
|
+
// the announce loop and take discovery down for the whole session, rather than
|
|
687
|
+
// costing this one namespace a turn.
|
|
688
|
+
let request;
|
|
689
|
+
try {
|
|
690
|
+
request = await this.#session.openBi();
|
|
691
|
+
const stream = request;
|
|
692
|
+
// Bounded, like the subscriber's own request: the loop is single-threaded over
|
|
693
|
+
// advertisements, so a peer that takes the stream and answers nothing strands
|
|
694
|
+
// every publish and withdrawal behind it. Timing out costs this namespace a
|
|
695
|
+
// turn and leaves it outstanding, which the retry above re-offers.
|
|
696
|
+
await withTimeout((async () => {
|
|
697
|
+
await stream.writer.u53(PublishNamespace.id);
|
|
698
|
+
const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster: this.#advert });
|
|
699
|
+
await msg.encode(stream.writer, this.#session.version);
|
|
700
|
+
// Read response (RequestOk and PublishNamespaceOk share 0x07)
|
|
701
|
+
const respTypeId = await stream.reader.u53();
|
|
702
|
+
if (respTypeId === RequestError.id) {
|
|
703
|
+
// The peer named how long to stay away, in milliseconds. Draft-14/15
|
|
704
|
+
// carry no such field, so a 0 there says nothing and our own backoff
|
|
705
|
+
// stands; everywhere else 0 means it does not want this again.
|
|
706
|
+
const err = await RequestError.decode(stream.reader, this.#session.version);
|
|
707
|
+
const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15;
|
|
708
|
+
if (!legacy) {
|
|
709
|
+
refused.set(path, err.retryInterval === 0n ? "never" : Date.now() + Number(err.retryInterval));
|
|
710
|
+
}
|
|
711
|
+
throw new Error(`PublishNamespace rejected: ${err.errorCode} ${err.reasonPhrase}`);
|
|
712
|
+
}
|
|
713
|
+
if (respTypeId !== RequestOk.id) {
|
|
714
|
+
throw new Error(`PublishNamespace rejected: typeId=0x${respTypeId.toString(16)}`);
|
|
715
|
+
}
|
|
716
|
+
// Draft-14 sends PublishNamespaceOk (requestId only, no parameters)
|
|
717
|
+
if (this.#session.version === Version.DRAFT_14) {
|
|
718
|
+
await PublishNamespaceOk.decode(stream.reader, this.#session.version);
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
await RequestOk.decode(stream.reader, this.#session.version);
|
|
722
|
+
}
|
|
723
|
+
})(), ADVERTISE_TIMEOUT_MS, `advertisement timed out after ${ADVERTISE_TIMEOUT_MS}ms waiting for the peer's answer`);
|
|
724
|
+
requests.set(path, { path, requestId, stream: request });
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
const e = error(err);
|
|
729
|
+
console.warn(`announce failed: broadcast=${path} error=${reason(e)}`);
|
|
730
|
+
request?.abort(e);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Close out a namespace's PUBLISH_NAMESPACE request with PUBLISH_NAMESPACE_DONE.
|
|
736
|
+
*/
|
|
737
|
+
async #withdraw(path, requests) {
|
|
738
|
+
const request = requests.get(path);
|
|
739
|
+
if (!request)
|
|
740
|
+
return;
|
|
741
|
+
requests.delete(path);
|
|
742
|
+
// Draft-17+ removed PUBLISH_NAMESPACE_DONE: the close below is the whole
|
|
743
|
+
// withdrawal. Sending it anyway puts the type on the wire before the body throws,
|
|
744
|
+
// and a receiver reading 0x09 there has no choice but to treat it as a protocol
|
|
745
|
+
// violation.
|
|
746
|
+
const version = this.#session.version;
|
|
747
|
+
if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) {
|
|
748
|
+
try {
|
|
749
|
+
await request.stream.writer.u53(PublishNamespaceDone.id);
|
|
750
|
+
const done = new PublishNamespaceDone({ trackNamespace: request.path, requestId: request.requestId });
|
|
751
|
+
await done.encode(request.stream.writer, version);
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
// Stream might already be closed
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
request.stream.close();
|
|
286
758
|
}
|
|
287
759
|
/**
|
|
288
760
|
* Handles an incoming TRACK_STATUS_REQUEST on a bidi stream.
|
|
@@ -313,18 +785,163 @@ export class Publisher {
|
|
|
313
785
|
}
|
|
314
786
|
stream.close();
|
|
315
787
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// Consumer already closed, will be cleaned up
|
|
788
|
+
/**
|
|
789
|
+
* Closes every published broadcast and stops accepting new ones.
|
|
790
|
+
*
|
|
791
|
+
* @internal
|
|
792
|
+
*/
|
|
793
|
+
close() {
|
|
794
|
+
this.#broadcasts.update((broadcasts) => {
|
|
795
|
+
for (const broadcast of broadcasts?.values() ?? []) {
|
|
796
|
+
broadcast.close();
|
|
326
797
|
}
|
|
798
|
+
return undefined;
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Take one group out of a cache view, without waiting.
|
|
804
|
+
*
|
|
805
|
+
* A fill's group is at or below the Largest Object snapshot, so it is either still in the
|
|
806
|
+
* retained window or gone for good: nothing republishes an old sequence, and waiting for one
|
|
807
|
+
* would park for the life of the track. Scanning past it, or running dry, is a cache miss,
|
|
808
|
+
* which the caller turns into the draft's fill-failure reset.
|
|
809
|
+
*/
|
|
810
|
+
function takeGroup(cache, sequence) {
|
|
811
|
+
for (;;) {
|
|
812
|
+
const group = cache.tryRecvGroup();
|
|
813
|
+
if (!group)
|
|
814
|
+
throw new Error(`group not found: ${sequence}`);
|
|
815
|
+
if (group.sequence === sequence)
|
|
816
|
+
return group;
|
|
817
|
+
group.close();
|
|
818
|
+
if (group.sequence > sequence)
|
|
819
|
+
throw new Error(`group not found: ${sequence}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/** `a - b`, saturating at zero the way the draft's unsigned arithmetic does. */
|
|
823
|
+
function saturatingSub(a, b) {
|
|
824
|
+
return a > b ? a - b : 0n;
|
|
825
|
+
}
|
|
826
|
+
/** Snapshot the live edge of a track. */
|
|
827
|
+
function liveEdge(track) {
|
|
828
|
+
const latest = track.latest();
|
|
829
|
+
if (latest === undefined)
|
|
830
|
+
return {};
|
|
831
|
+
const largest = track.largest();
|
|
832
|
+
if (!largest)
|
|
833
|
+
return { latest: BigInt(latest), next: { group: BigInt(latest), object: 0n } };
|
|
834
|
+
const group = BigInt(largest.group);
|
|
835
|
+
const object = BigInt(largest.frame);
|
|
836
|
+
return { latest: BigInt(latest), largest: { group, object }, next: { group, object: object + 1n } };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Resolve a SUBSCRIBE's Location Filter into the range to serve.
|
|
840
|
+
*
|
|
841
|
+
* Only draft-20 is honored. Earlier drafts have a Filter Type tag whose absolute forms we
|
|
842
|
+
* never served, and starting to interpret them now would change what an existing peer
|
|
843
|
+
* receives; draft-20 is also the first version whose relative forms can name a past group
|
|
844
|
+
* without the subscriber knowing Largest Object.
|
|
845
|
+
*/
|
|
846
|
+
function subscribeRange(msg, edge, version) {
|
|
847
|
+
if (!Filter.isDraft20(version)) {
|
|
848
|
+
if (msg.filter.kind !== "nextObject" && msg.filter.kind !== "unfiltered") {
|
|
849
|
+
console.warn(`filter not supported before draft-20, ignoring: ${msg.filter.kind}`);
|
|
327
850
|
}
|
|
851
|
+
return {};
|
|
852
|
+
}
|
|
853
|
+
return filterRange(msg.filter, edge);
|
|
854
|
+
}
|
|
855
|
+
/** The Locations a single Location Filter selects, resolved against the live edge. */
|
|
856
|
+
function filterRange(filter, edge) {
|
|
857
|
+
switch (filter.kind) {
|
|
858
|
+
// No restriction. moq-lite starts at the beginning of the latest group, which is the
|
|
859
|
+
// join point it is built around; a subscription passes objects as they are published,
|
|
860
|
+
// so an absent filter is not a request to replay history.
|
|
861
|
+
case "unfiltered":
|
|
862
|
+
return {};
|
|
863
|
+
// `{Largest.Group, Largest.Object + 1}`. Everything below it, including the already
|
|
864
|
+
// published head of the current group, is outside the requested range, so the join is
|
|
865
|
+
// mid-group by construction. The draft pairs this with a fill when the subscriber
|
|
866
|
+
// wants the head; see {@link Publisher.runFill}.
|
|
867
|
+
case "nextObject":
|
|
868
|
+
return { start: edge.next };
|
|
869
|
+
// `{Largest.Group + 1 - groups, 0}`: 0 is the next group and 1 is the current one.
|
|
870
|
+
// Counted from `Largest.Group`, which sits below the newest group while that group has
|
|
871
|
+
// no objects yet; only with no largest at all does the newest group stand in for it.
|
|
872
|
+
case "relative": {
|
|
873
|
+
const base = edge.largest?.group ?? edge.latest;
|
|
874
|
+
if (base === undefined)
|
|
875
|
+
return {};
|
|
876
|
+
return { start: { group: saturatingSub(base + 1n, filter.groups), object: 0n } };
|
|
877
|
+
}
|
|
878
|
+
case "absolute":
|
|
879
|
+
return {
|
|
880
|
+
start: { group: filter.startGroup, object: filter.startObject },
|
|
881
|
+
end: filter.endGroup === undefined ? undefined : { group: filter.endGroup, object: filter.endObject },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Trim a group to the filter's object bounds, so nothing outside the requested range is
|
|
887
|
+
* sent. Interior groups are served whole.
|
|
888
|
+
*/
|
|
889
|
+
function groupSlice(range, sequence) {
|
|
890
|
+
const at = BigInt(sequence);
|
|
891
|
+
return {
|
|
892
|
+
skip: range.start?.group === at ? Number(range.start.object) : 0,
|
|
893
|
+
until: range.end?.group === at && range.end.object !== undefined ? Number(range.end.object) + 1 : undefined,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Resolve a fill request using the Fetch rules: relative to Largest Object and never
|
|
898
|
+
* extending beyond it. An omitted Location Filter inherits the subscription's.
|
|
899
|
+
*/
|
|
900
|
+
function fillRange(fill, subscription, largest) {
|
|
901
|
+
// A Range Filter narrows which objects pass, which we do not implement; serving the
|
|
902
|
+
// unfiltered range instead would deliver objects the peer excluded, so refuse it.
|
|
903
|
+
if (fill.rangeFilters)
|
|
904
|
+
return { kind: "unsupported" };
|
|
905
|
+
const filter = fill.filter ?? subscription;
|
|
906
|
+
// Nothing published (or no precise edge to cap at) means no fill is servable; an empty
|
|
907
|
+
// range opens no stream.
|
|
908
|
+
if (!largest)
|
|
909
|
+
return { kind: "empty" };
|
|
910
|
+
let start;
|
|
911
|
+
switch (filter.kind) {
|
|
912
|
+
// A Fetch without a filter is the whole track up to Largest Object.
|
|
913
|
+
case "unfiltered":
|
|
914
|
+
start = { group: 0n, object: 0n };
|
|
915
|
+
break;
|
|
916
|
+
// One past the edge, which for a Fetch is always empty.
|
|
917
|
+
case "nextObject":
|
|
918
|
+
return { kind: "empty" };
|
|
919
|
+
case "relative":
|
|
920
|
+
start = { group: saturatingSub(largest.group + 1n, filter.groups), object: 0n };
|
|
921
|
+
break;
|
|
922
|
+
case "absolute":
|
|
923
|
+
start = { group: filter.startGroup, object: filter.startObject };
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
// Cap the requested end at Largest Object.
|
|
927
|
+
let end = { group: largest.group, object: largest.object };
|
|
928
|
+
if (filter.kind === "absolute" && filter.endGroup !== undefined) {
|
|
929
|
+
const below = filter.endGroup < largest.group ||
|
|
930
|
+
(filter.endGroup === largest.group && filter.endObject !== undefined && filter.endObject < largest.object);
|
|
931
|
+
if (below)
|
|
932
|
+
end = { group: filter.endGroup, object: filter.endObject };
|
|
933
|
+
}
|
|
934
|
+
if (start.group > end.group ||
|
|
935
|
+
(start.group === end.group && end.object !== undefined && end.object < start.object)) {
|
|
936
|
+
return { kind: "empty" };
|
|
328
937
|
}
|
|
938
|
+
if (start.group !== end.group)
|
|
939
|
+
return { kind: "unsupported" };
|
|
940
|
+
return {
|
|
941
|
+
kind: "group",
|
|
942
|
+
sequence: start.group,
|
|
943
|
+
skip: start.object,
|
|
944
|
+
until: end.object === undefined ? undefined : end.object + 1n,
|
|
945
|
+
};
|
|
329
946
|
}
|
|
330
947
|
//# sourceMappingURL=publisher.js.map
|