@norskvideo/moq-net 0.1.8 → 0.2.1
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 +2 -2
- package/announce.d.ts +7 -0
- package/announce.d.ts.map +1 -0
- package/announce.js +8 -0
- package/announce.js.map +1 -0
- package/announced.d.ts +49 -91
- package/announced.d.ts.map +1 -1
- package/announced.js +21 -156
- package/announced.js.map +1 -1
- package/bandwidth.d.ts +163 -0
- package/bandwidth.d.ts.map +1 -0
- package/bandwidth.js +304 -0
- package/bandwidth.js.map +1 -0
- package/bandwidth_api.d.ts +7 -0
- package/bandwidth_api.d.ts.map +1 -0
- package/bandwidth_api.js +8 -0
- package/bandwidth_api.js.map +1 -0
- package/broadcast.d.ts +44 -35
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +104 -60
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +16 -1
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +52 -28
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts.map +1 -1
- package/connection/browser.js +9 -7
- package/connection/browser.js.map +1 -1
- package/connection/connect.d.ts +30 -6
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +110 -54
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +17 -21
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/forward.d.ts +2 -0
- package/connection/forward.d.ts.map +1 -0
- package/connection/forward.js +173 -0
- package/connection/forward.js.map +1 -0
- package/connection/handshake.d.ts +1 -0
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +5 -2
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +5 -5
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +4 -5
- package/connection/index.js.map +1 -1
- package/connection/pool.d.ts +186 -0
- package/connection/pool.d.ts.map +1 -0
- package/connection/pool.js +361 -0
- package/connection/pool.js.map +1 -0
- package/connection/reload.d.ts +14 -93
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +217 -81
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +3 -26
- package/connection/stats.d.ts.map +1 -1
- package/connection/stats.js.map +1 -1
- package/connection/transport.d.ts +0 -7
- package/connection/transport.d.ts.map +1 -1
- package/consume.d.ts +1 -43
- package/consume.d.ts.map +1 -1
- package/consume.js +1 -1
- package/consume.js.map +1 -1
- package/error.d.ts +180 -29
- package/error.d.ts.map +1 -1
- package/error.js +331 -16
- package/error.js.map +1 -1
- package/errors.d.ts +7 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +8 -0
- package/errors.js.map +1 -0
- package/group.d.ts +9 -43
- package/group.d.ts.map +1 -1
- package/group.js +284 -69
- package/group.js.map +1 -1
- package/hop.d.ts +115 -0
- package/hop.d.ts.map +1 -0
- package/hop.js +119 -0
- package/hop.js.map +1 -0
- package/ietf/adapter.d.ts +5 -1
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +105 -60
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +1 -78
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/cluster.d.ts +9 -123
- package/ietf/cluster.d.ts.map +1 -1
- package/ietf/cluster.js +84 -44
- package/ietf/cluster.js.map +1 -1
- package/ietf/connection.d.ts +6 -72
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +62 -56
- package/ietf/connection.js.map +1 -1
- package/ietf/error.d.ts +11 -0
- package/ietf/error.d.ts.map +1 -0
- package/ietf/error.js +193 -0
- package/ietf/error.js.map +1 -0
- package/ietf/fetch.d.ts +7 -20
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +52 -22
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +2 -0
- package/ietf/filter.d.ts.map +1 -1
- package/ietf/filter.js +10 -1
- package/ietf/filter.js.map +1 -1
- package/ietf/goaway.d.ts.map +1 -1
- package/ietf/goaway.js +22 -5
- package/ietf/goaway.js.map +1 -1
- package/ietf/hidden.d.ts +2 -0
- package/ietf/hidden.d.ts.map +1 -0
- package/ietf/hidden.js +30 -0
- package/ietf/hidden.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +2 -0
- package/ietf/index.js.map +1 -1
- package/ietf/object.d.ts +15 -8
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +51 -36
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +14 -2
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +97 -29
- package/ietf/parameters.js.map +1 -1
- package/ietf/properties.d.ts +1 -0
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +14 -0
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts +19 -2
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +40 -6
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +25 -0
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +63 -0
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +1 -82
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +483 -235
- package/ietf/publisher.js.map +1 -1
- package/ietf/solicit.d.ts +1 -40
- package/ietf/solicit.d.ts.map +1 -1
- package/ietf/subscribe.d.ts +8 -6
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +33 -27
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +8 -2
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +18 -8
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +1 -65
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +337 -123
- package/ietf/subscriber.js.map +1 -1
- package/ietf/token.d.ts +2 -0
- package/ietf/token.d.ts.map +1 -0
- package/ietf/token.js +99 -0
- package/ietf/token.js.map +1 -0
- package/ietf/track.d.ts +4 -0
- package/ietf/track.d.ts.map +1 -1
- package/ietf/track.js +6 -20
- package/ietf/track.js.map +1 -1
- package/ietf/version.d.ts +12 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +13 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +12 -7
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +115 -1
- package/internal.d.ts.map +1 -1
- package/internal.js +108 -0
- package/internal.js.map +1 -1
- package/lite/announce.d.ts +60 -10
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +176 -31
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +9 -59
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +34 -35
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +3 -2
- package/lite/datagram.d.ts.map +1 -1
- package/lite/datagram.js +7 -8
- package/lite/datagram.js.map +1 -1
- package/lite/fetch.d.ts +15 -1
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +39 -7
- package/lite/fetch.js.map +1 -1
- package/lite/goaway.d.ts.map +1 -1
- package/lite/goaway.js +7 -1
- package/lite/goaway.js.map +1 -1
- package/lite/group.d.ts +30 -12
- package/lite/group.d.ts.map +1 -1
- package/lite/group.js +68 -26
- package/lite/group.js.map +1 -1
- package/lite/message.d.ts +2 -2
- package/lite/message.d.ts.map +1 -1
- package/lite/message.js +14 -5
- package/lite/message.js.map +1 -1
- package/lite/priority.d.ts +1 -61
- package/lite/priority.d.ts.map +1 -1
- package/lite/priority.js +4 -5
- package/lite/priority.js.map +1 -1
- package/lite/publisher.d.ts +1 -69
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +630 -262
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +8 -8
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +33 -30
- package/lite/setup.js.map +1 -1
- package/lite/subscribe.d.ts +71 -17
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +205 -53
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +15 -56
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +451 -247
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +4 -10
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +34 -29
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +42 -6
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +126 -10
- package/lite/version.js.map +1 -1
- package/origin.d.ts +256 -29
- package/origin.d.ts.map +1 -1
- package/origin.js +1427 -37
- package/origin.js.map +1 -1
- package/package.json +8 -3
- package/path.d.ts +25 -7
- package/path.d.ts.map +1 -1
- package/path.js +5 -3
- package/path.js.map +1 -1
- package/stream.d.ts +73 -14
- package/stream.d.ts.map +1 -1
- package/stream.js +372 -141
- package/stream.js.map +1 -1
- package/tail.d.ts +18 -0
- package/tail.d.ts.map +1 -0
- package/tail.js +167 -0
- package/tail.js.map +1 -0
- package/time.d.ts +15 -2
- package/time.d.ts.map +1 -1
- package/time.js +28 -9
- package/time.js.map +1 -1
- package/track.d.ts +211 -83
- package/track.d.ts.map +1 -1
- package/track.js +816 -205
- package/track.js.map +1 -1
- package/util/abort.d.ts +2 -0
- package/util/abort.d.ts.map +1 -0
- package/util/abort.js +20 -0
- package/util/abort.js.map +1 -0
- package/util/log.d.ts +5 -0
- package/util/log.d.ts.map +1 -0
- package/util/log.js +17 -0
- package/util/log.js.map +1 -0
- package/util/u64.d.ts +39 -0
- package/util/u64.d.ts.map +1 -0
- package/util/u64.js +83 -0
- package/util/u64.js.map +1 -0
- package/util/varint.d.ts +29 -0
- package/util/varint.d.ts.map +1 -0
- package/util/varint.js +198 -0
- package/util/varint.js.map +1 -0
- package/varint.d.ts +10 -6
- package/varint.d.ts.map +1 -1
- package/varint.js +40 -237
- package/varint.js.map +1 -1
- package/wire.d.ts +80 -0
- package/wire.d.ts.map +1 -0
- package/wire.js +32 -0
- package/wire.js.map +1 -0
- package/zod.d.ts +1 -1
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/mock.d.ts +0 -66
- package/mock.d.ts.map +0 -1
- package/mock.js +0 -243
- package/mock.js.map +0 -1
package/origin.js
CHANGED
|
@@ -1,59 +1,1449 @@
|
|
|
1
1
|
/* @ts-self-types="./origin.d.ts" */
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A broadcast routing table, independent of any connection.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Publish broadcasts into an origin and hand the origin to one or more connections to
|
|
6
|
+
* serve them; the broadcasts outlive any single session. Hand the same (or another)
|
|
7
|
+
* origin to a connection's `consume` option and the peer's announced routes appear
|
|
8
|
+
* in the table too: each route covers a path prefix, and a request for a path under
|
|
9
|
+
* it resolves through the session that announced it. Mirrors the `origin` module in
|
|
7
10
|
* `rs/moq-net`.
|
|
8
11
|
*
|
|
9
12
|
* @module
|
|
10
13
|
*/
|
|
11
|
-
import
|
|
14
|
+
import { Derived, getter, Once, Signal } from "@norskvideo/moq-signals";
|
|
15
|
+
import * as announce from "./announced.js";
|
|
16
|
+
import * as broadcast from "./broadcast.js";
|
|
17
|
+
import { StreamCode, StreamError } from "./error.js";
|
|
18
|
+
import { isAnonymous, Route, routesEqual } from "./hop.js";
|
|
19
|
+
import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "./internal.js";
|
|
20
|
+
import * as Path from "./path.js";
|
|
21
|
+
import { registerWire, wireOf } from "./wire.js";
|
|
22
|
+
export { isAnonymous } from "./hop.js";
|
|
23
|
+
/** The rooted permissions shared by a handle and every route it inserts. */
|
|
24
|
+
class Scope {
|
|
25
|
+
static all = new Scope(Path.empty());
|
|
26
|
+
root;
|
|
27
|
+
allowed;
|
|
28
|
+
constructor(root, allowed) {
|
|
29
|
+
this.root = root;
|
|
30
|
+
this.allowed = allowed;
|
|
31
|
+
}
|
|
32
|
+
narrow(root, patterns) {
|
|
33
|
+
const joined = Path.encode(Path.join(this.root, root));
|
|
34
|
+
const rooted = patterns.rooted(joined);
|
|
35
|
+
const allowed = this.allowed?.intersect(rooted) ?? rooted;
|
|
36
|
+
if (allowed.size === 0)
|
|
37
|
+
throw new Error("origin scopes do not overlap");
|
|
38
|
+
return new Scope(joined, allowed);
|
|
39
|
+
}
|
|
40
|
+
matches(path) {
|
|
41
|
+
return this.allowed?.matches(path) ?? true;
|
|
42
|
+
}
|
|
43
|
+
path(path) {
|
|
44
|
+
const joined = Path.encode(Path.join(this.root, path));
|
|
45
|
+
if (!this.matches(joined))
|
|
46
|
+
throw new Error("path is outside the origin scope");
|
|
47
|
+
return joined;
|
|
48
|
+
}
|
|
49
|
+
prefix(prefix) {
|
|
50
|
+
const joined = Path.encode(Path.join(this.root, prefix));
|
|
51
|
+
if (this.allowed && !this.allowed.overlaps(Path.Pattern.subtree(joined))) {
|
|
52
|
+
throw new Error("prefix is outside the origin scope");
|
|
53
|
+
}
|
|
54
|
+
return joined;
|
|
55
|
+
}
|
|
56
|
+
patterns(pattern = Path.Pattern.all()) {
|
|
57
|
+
const rooted = new Path.Patterns([pattern.rooted(this.root)]);
|
|
58
|
+
return this.allowed?.intersect(rooted) ?? rooted;
|
|
59
|
+
}
|
|
60
|
+
/** Omit nested heads because the outer subscription already carries their routes. */
|
|
61
|
+
heads() {
|
|
62
|
+
if (!this.allowed)
|
|
63
|
+
return [Path.empty()];
|
|
64
|
+
const heads = [...new Set([...this.allowed.rebase(this.root)].map(scopeHead))].sort();
|
|
65
|
+
return heads.filter((head) => !heads.some((other) => other !== head && Path.hasPrefix(other, head)));
|
|
66
|
+
}
|
|
67
|
+
/** The exact paths within this scope, relative to its root. */
|
|
68
|
+
projectPaths(values) {
|
|
69
|
+
if (!values || this === Scope.all)
|
|
70
|
+
return values;
|
|
71
|
+
const out = new Map();
|
|
72
|
+
for (const [path, value] of values) {
|
|
73
|
+
if (!this.matches(path))
|
|
74
|
+
continue;
|
|
75
|
+
const relative = Path.stripPrefix(this.root, path);
|
|
76
|
+
if (relative !== null)
|
|
77
|
+
out.set(relative, value);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The advertisements that may serve this scope, relative to its root. Every prefix at or
|
|
83
|
+
* above the root presents as the empty path, most specific first, since that is the order
|
|
84
|
+
* a request beneath the root resolves in.
|
|
85
|
+
*/
|
|
86
|
+
projectRoutes(values) {
|
|
87
|
+
if (!values || this === Scope.all)
|
|
88
|
+
return values;
|
|
89
|
+
const out = new Map();
|
|
90
|
+
const covering = [];
|
|
91
|
+
const allowed = this.allowed && [...this.allowed];
|
|
92
|
+
for (const [path, candidates] of values) {
|
|
93
|
+
const relative = Path.stripPrefix(this.root, path);
|
|
94
|
+
const above = relative === null || relative === Path.empty();
|
|
95
|
+
if (above && !Path.hasPrefix(path, this.root))
|
|
96
|
+
continue;
|
|
97
|
+
const visible = candidates
|
|
98
|
+
.filter((value) => !allowed || allowed.some((pattern) => advertOverlaps(value, path, pattern)))
|
|
99
|
+
// The claim moves with the key, so it compares against root-relative requests.
|
|
100
|
+
.map((value) => (value.claim ? { ...value, claim: value.claim.rebase(this.root) } : value));
|
|
101
|
+
if (visible.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
if (!above) {
|
|
104
|
+
out.set(relative, visible);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Hold the empty path's place in the order until every covering prefix is known.
|
|
108
|
+
if (covering.length === 0)
|
|
109
|
+
out.set(Path.empty(), []);
|
|
110
|
+
covering.push([path, visible]);
|
|
111
|
+
}
|
|
112
|
+
if (covering.length > 0) {
|
|
113
|
+
covering.sort(([a], [b]) => b.length - a.length);
|
|
114
|
+
out.set(Path.empty(), covering.flatMap(([, visible]) => visible));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Whether the route advertised at `prefix` may serve any path `pattern` admits. */
|
|
120
|
+
function advertOverlaps(advert, prefix, pattern) {
|
|
121
|
+
return advert.claim ? advert.claim.overlaps(pattern) : scopeOverlaps(pattern, prefix);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Presents advertised prefixes relative to a root. Every prefix at or above the root
|
|
125
|
+
* collapses to the empty path, where the most specific one wins, since that is the route
|
|
126
|
+
* a request beneath the root resolves through.
|
|
127
|
+
*/
|
|
128
|
+
class CoveringRoot {
|
|
129
|
+
#root;
|
|
130
|
+
#covering;
|
|
131
|
+
constructor(root) {
|
|
132
|
+
this.#root = root;
|
|
133
|
+
}
|
|
134
|
+
/** The presented path for `path`, or undefined when it is outside the root or a broader cover. */
|
|
135
|
+
relative(path) {
|
|
136
|
+
const relative = Path.stripPrefix(this.#root, path);
|
|
137
|
+
if (relative !== null && relative !== Path.empty())
|
|
138
|
+
return relative;
|
|
139
|
+
if (relative === null && !Path.hasPrefix(path, this.#root))
|
|
140
|
+
return undefined;
|
|
141
|
+
if (this.#covering !== undefined && !Path.hasPrefix(this.#covering, path))
|
|
142
|
+
return undefined;
|
|
143
|
+
this.#covering = path;
|
|
144
|
+
return Path.empty();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Orders advertisements at one prefix: the better route, then a local broadcast on a tie, then fewer hops. */
|
|
148
|
+
function compareCandidates(a, b) {
|
|
149
|
+
return (compareRoutes(a.route, b.route) ||
|
|
150
|
+
Number(b.exact) - Number(a.exact) ||
|
|
151
|
+
a.route.hops.length - b.route.hops.length);
|
|
152
|
+
}
|
|
153
|
+
/** Orders two routes by preference: identified before anonymous, then lower warm cost, then lower cold cost. */
|
|
154
|
+
function compareRoutes(a, b) {
|
|
155
|
+
const anonymous = Number(isAnonymous(a)) - Number(isAnonymous(b));
|
|
156
|
+
if (anonymous !== 0)
|
|
157
|
+
return anonymous;
|
|
158
|
+
if (a.cost.warm !== b.cost.warm)
|
|
159
|
+
return a.cost.warm < b.cost.warm ? -1 : 1;
|
|
160
|
+
if (a.cost.cold !== b.cost.cold)
|
|
161
|
+
return a.cost.cold < b.cost.cold ? -1 : 1;
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
/** The preferred of `entries` (newest first) not skipped: the best route, then fewest hops, then newest. */
|
|
165
|
+
function preferredEntry(entries, skip) {
|
|
166
|
+
let best;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (skip?.(entry))
|
|
169
|
+
continue;
|
|
170
|
+
if (!best) {
|
|
171
|
+
best = entry;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const a = entry.route.peek();
|
|
175
|
+
const b = best.route.peek();
|
|
176
|
+
const order = compareRoutes(a, b) || a.hops.length - b.hops.length;
|
|
177
|
+
if (order < 0)
|
|
178
|
+
best = entry;
|
|
179
|
+
}
|
|
180
|
+
return best;
|
|
181
|
+
}
|
|
182
|
+
/** Whether a session received `entry`, so it is never forwarded to a peer. */
|
|
183
|
+
function received(entry) {
|
|
184
|
+
return !entry.originated;
|
|
185
|
+
}
|
|
186
|
+
function noCapacity() {
|
|
187
|
+
return new StreamError(StreamCode.NoCapacity, { message: "no capacity" });
|
|
188
|
+
}
|
|
189
|
+
/** A served route from {@link Producer.dynamic}: the queue a handler drains. */
|
|
190
|
+
class ServeState {
|
|
191
|
+
root;
|
|
192
|
+
constructor(root) {
|
|
193
|
+
this.root = root;
|
|
194
|
+
}
|
|
195
|
+
queue = new Signal([]);
|
|
196
|
+
pending = new Map();
|
|
197
|
+
served = new Map();
|
|
198
|
+
rejected = new Map();
|
|
199
|
+
// demand() is the only reader of `rejected`. A Consumer.request refusal never
|
|
200
|
+
// re-enqueues, so storing the error without a waiter would pin every unique
|
|
201
|
+
// path until the route dies.
|
|
202
|
+
demanding = new Map();
|
|
203
|
+
closed = new Once();
|
|
204
|
+
settled = new Signal(0);
|
|
205
|
+
onChange = () => { };
|
|
206
|
+
onReject = () => { };
|
|
207
|
+
enqueue(path) {
|
|
208
|
+
if (this.closed.peek() !== undefined)
|
|
209
|
+
return;
|
|
210
|
+
this.rejected.delete(path);
|
|
211
|
+
if (this.pending.has(path))
|
|
212
|
+
return;
|
|
213
|
+
const live = this.served.get(path);
|
|
214
|
+
if (live && live.closed.peek() === undefined)
|
|
215
|
+
return;
|
|
216
|
+
const request = makeRequest(Path.stripPrefix(this.root, path) ?? Path.empty(), this);
|
|
217
|
+
this.pending.set(path, request);
|
|
218
|
+
this.queue.mutate((queue) => {
|
|
219
|
+
queue.push(request);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
accept(request, front) {
|
|
223
|
+
const path = Path.join(this.root, request.path);
|
|
224
|
+
if (this.closed.peek() !== undefined || this.pending.get(path) !== request) {
|
|
225
|
+
front.close();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.pending.delete(path);
|
|
229
|
+
const existing = this.served.get(path);
|
|
230
|
+
if (existing && existing.closed.peek() === undefined) {
|
|
231
|
+
front.close();
|
|
232
|
+
this.onChange(path);
|
|
233
|
+
this.settled.update((n) => n + 1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.served.set(path, front);
|
|
237
|
+
void front.closed.then(() => {
|
|
238
|
+
if (this.served.get(path) !== front)
|
|
239
|
+
return;
|
|
240
|
+
this.served.delete(path);
|
|
241
|
+
this.onChange(path);
|
|
242
|
+
});
|
|
243
|
+
this.onChange(path);
|
|
244
|
+
this.settled.update((n) => n + 1);
|
|
245
|
+
}
|
|
246
|
+
reject(request, err) {
|
|
247
|
+
const path = Path.join(this.root, request.path);
|
|
248
|
+
if (this.pending.get(path) !== request)
|
|
249
|
+
return;
|
|
250
|
+
this.pending.delete(path);
|
|
251
|
+
if (this.demanding.has(path))
|
|
252
|
+
this.rejected.set(path, err);
|
|
253
|
+
this.onReject(path, err);
|
|
254
|
+
this.settled.update((n) => n + 1);
|
|
255
|
+
}
|
|
256
|
+
close(abort) {
|
|
257
|
+
if (this.closed.peek() !== undefined)
|
|
258
|
+
return;
|
|
259
|
+
const err = abort ?? noCapacity();
|
|
260
|
+
this.closed.set(err);
|
|
261
|
+
const queued = [...this.pending.values()];
|
|
262
|
+
this.pending.clear();
|
|
263
|
+
this.queue.mutate((queue) => {
|
|
264
|
+
queue.length = 0;
|
|
265
|
+
});
|
|
266
|
+
for (const request of queued) {
|
|
267
|
+
finishRequest(request, err);
|
|
268
|
+
}
|
|
269
|
+
for (const [path, front] of this.served) {
|
|
270
|
+
front.close();
|
|
271
|
+
this.onChange(path);
|
|
272
|
+
}
|
|
273
|
+
this.served.clear();
|
|
274
|
+
this.rejected.clear();
|
|
275
|
+
this.demanding.clear();
|
|
276
|
+
this.settled.update((n) => n + 1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** A table mutation invalidates the shared route snapshot before its async notification. */
|
|
280
|
+
class VersionedSignal extends Signal {
|
|
281
|
+
version = 0;
|
|
282
|
+
set(value, notify) {
|
|
283
|
+
this.version++;
|
|
284
|
+
super.set(value, notify);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Reactive backing state shared by origin producers and consumers. */
|
|
288
|
+
class OriginState {
|
|
289
|
+
// Both tables decouple the application producing into the origin from the
|
|
290
|
+
// connections serving or feeding it. Undefined once the origin closes, so late
|
|
291
|
+
// writes fail loudly.
|
|
292
|
+
//
|
|
293
|
+
// Created is what this endpoint publishes, keyed by exact path, announced or not.
|
|
294
|
+
// Local is the announced subset, with its route in advertisedLocal: a broadcast
|
|
295
|
+
// exists for nobody, here or at a peer, until it announces. Routes is the
|
|
296
|
+
// advertisement table: prefixes a dynamic handle or a received session covers,
|
|
297
|
+
// newest first. Local and routes stay separate so a session can never announce a
|
|
298
|
+
// received entry back to a peer, which is what makes an origin shared by both
|
|
299
|
+
// directions echo-free.
|
|
300
|
+
created = new Map();
|
|
301
|
+
local = new VersionedSignal(new Map());
|
|
302
|
+
advertisedLocal = new VersionedSignal(new Map());
|
|
303
|
+
routes = new VersionedSignal(new Map());
|
|
304
|
+
#snapshotVersion = "";
|
|
305
|
+
#snapshot = { candidates: new Map(), routes: new Map(), visible: new Map() };
|
|
306
|
+
/** The full route table is built once per mutation, regardless of observer count. */
|
|
307
|
+
available = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().routes);
|
|
308
|
+
/** {@link available} without hidden routes, for unscoped readers that did not opt in. */
|
|
309
|
+
visible = new Derived([this.local, this.advertisedLocal, this.routes], () => this.snapshot().visible);
|
|
310
|
+
snapshot() {
|
|
311
|
+
const version = `${this.local.version}/${this.advertisedLocal.version}/${this.routes.version}`;
|
|
312
|
+
if (version === this.#snapshotVersion)
|
|
313
|
+
return this.#snapshot;
|
|
314
|
+
const candidates = this.candidates();
|
|
315
|
+
const available = new Map();
|
|
316
|
+
const visible = new Map();
|
|
317
|
+
for (const [path, [best]] of candidates) {
|
|
318
|
+
available.set(path, best.route);
|
|
319
|
+
if (!hiddenBelow(Path.empty(), path))
|
|
320
|
+
visible.set(path, best.route);
|
|
321
|
+
}
|
|
322
|
+
this.#snapshot = { candidates, routes: available, visible };
|
|
323
|
+
this.#snapshotVersion = version;
|
|
324
|
+
return this.#snapshot;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Every advertisement per prefix, most preferred first, without the `skip`ped entries.
|
|
328
|
+
* Readers select after filtering by their scope, so a cheaper route they cannot see
|
|
329
|
+
* never hides one they can.
|
|
330
|
+
*/
|
|
331
|
+
candidates(skip) {
|
|
332
|
+
const out = new Map();
|
|
333
|
+
for (const [path, entries] of this.routes.peek() ?? []) {
|
|
334
|
+
const list = [];
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
if (skip?.(entry))
|
|
337
|
+
continue;
|
|
338
|
+
list.push({ identity: entry.identity, route: entry.route.peek(), claim: entry.claim, exact: false });
|
|
339
|
+
}
|
|
340
|
+
if (list.length > 0)
|
|
341
|
+
out.set(path, list);
|
|
342
|
+
}
|
|
343
|
+
const advertised = this.advertisedLocal.peek();
|
|
344
|
+
for (const [path, front] of this.local.peek() ?? []) {
|
|
345
|
+
const local = { identity: front, route: advertised?.get(path) ?? Route.default, exact: true };
|
|
346
|
+
const list = out.get(path);
|
|
347
|
+
if (list)
|
|
348
|
+
list.push(local);
|
|
349
|
+
else
|
|
350
|
+
out.set(path, [local]);
|
|
351
|
+
}
|
|
352
|
+
for (const list of out.values()) {
|
|
353
|
+
// Stable, so equal routes keep the table's newest-first order.
|
|
354
|
+
if (list.length > 1)
|
|
355
|
+
list.sort(compareCandidates);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
// Originated advertisements sessions should forward: exact-path announces plus
|
|
360
|
+
// originated dynamics. Identity is the local front or the route entry, so a
|
|
361
|
+
// republish diffs as retract-then-announce and a re-price as another active.
|
|
362
|
+
originated = new Signal(new Map());
|
|
363
|
+
// Broadcasts materialized from a served route, keyed by exact path. Shared by every
|
|
364
|
+
// request for the path so repeats reuse one accept; dropped (and closed) when the
|
|
365
|
+
// providing route goes away or the last request releases it.
|
|
366
|
+
materialized = new Map();
|
|
367
|
+
// Paths consumers asked for without waiting for an announcement; attached sessions
|
|
368
|
+
// answer them with blind subscriptions. Never announced: an answered request is assumed
|
|
369
|
+
// present, not known live, so it must not read as an availability claim.
|
|
370
|
+
requests = new Signal(new Map());
|
|
371
|
+
// How many sessions are attached, and how many of those support broadcast discovery.
|
|
372
|
+
// What backs the public `discovery` getter.
|
|
373
|
+
sessions = new Signal({ total: 0, discovery: 0 });
|
|
374
|
+
// How many things are prepared to answer a request: attached sessions, plus reconnecting
|
|
375
|
+
// connections that have no session right now but will. Zero means an unrouted path is
|
|
376
|
+
// unroutable rather than merely unanswered, which is the whole difference between "wait,
|
|
377
|
+
// this is coming" and "nothing here can ever serve you".
|
|
378
|
+
answerers = new Signal(0);
|
|
379
|
+
closed = new Once();
|
|
380
|
+
/**
|
|
381
|
+
* Recompute what `path` resolves to, waking only the requests watching that path.
|
|
382
|
+
*
|
|
383
|
+
* A no-op for a path nobody requested, so the common case (publishing into a table
|
|
384
|
+
* nobody is asking about) costs a map lookup. Call after any write that could change
|
|
385
|
+
* the answer for a single path.
|
|
386
|
+
*/
|
|
387
|
+
refresh(path) {
|
|
388
|
+
const slot = this.requests.peek()?.get(path);
|
|
389
|
+
if (!slot)
|
|
390
|
+
return;
|
|
391
|
+
slot.route.set(this.route(path, slot));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* `entry` refused `path` with `err`. A request still serving another source skips the
|
|
395
|
+
* refuser; one with nothing serving ends with `err`.
|
|
396
|
+
*/
|
|
397
|
+
refuse(path, entry, err) {
|
|
398
|
+
const slot = this.requests.peek()?.get(path);
|
|
399
|
+
if (!slot)
|
|
400
|
+
return;
|
|
401
|
+
// Only the route the request is waiting on speaks for it; a superseded one's answer is moot.
|
|
402
|
+
if (this.bestEntry(path, (candidate) => slot.refused.has(candidate)) !== entry)
|
|
403
|
+
return;
|
|
404
|
+
const serving = slot.route.peek();
|
|
405
|
+
if (serving && serving.closed.peek() === undefined) {
|
|
406
|
+
slot.refused.add(entry);
|
|
407
|
+
slot.route.set(this.route(path, slot));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.requests.mutate((map) => {
|
|
411
|
+
if (map?.get(path) === slot)
|
|
412
|
+
map.delete(path);
|
|
413
|
+
});
|
|
414
|
+
slot.answer?.close();
|
|
415
|
+
slot.answer = undefined;
|
|
416
|
+
slot.route.set(undefined);
|
|
417
|
+
this.releaseMaterialized(path);
|
|
418
|
+
for (const closed of slot.handles)
|
|
419
|
+
closed.set(err);
|
|
420
|
+
slot.handles.clear();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Recompute every open request covered by `prefix`, after a route was inserted or
|
|
424
|
+
* removed there: a route covers many paths, so a single-path refresh is not enough.
|
|
425
|
+
* Every materialized broadcast belongs to an open request, so rerouting them also
|
|
426
|
+
* releases a retracted route's session subscription even when nothing reads it again.
|
|
427
|
+
*/
|
|
428
|
+
refreshPrefix(prefix) {
|
|
429
|
+
for (const [path, slot] of this.requests.peek() ?? []) {
|
|
430
|
+
if (Path.hasPrefix(prefix, path))
|
|
431
|
+
slot.route.set(this.route(path, slot));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Rebuild the publisher-facing originated table after an advertisement write. */
|
|
435
|
+
rebuildOriginated() {
|
|
436
|
+
if (!this.local.peek() && !this.advertisedLocal.peek() && !this.routes.peek()) {
|
|
437
|
+
this.originated.set(undefined);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// A local broadcast and an originated dynamic at one path compete on cost, as they do for requests.
|
|
441
|
+
this.originated.set(this.candidates(received));
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Release the materialized broadcast for `path`, once its last request is gone: the
|
|
445
|
+
* cache exists to share one session subscription between requests, not to outlive
|
|
446
|
+
* them.
|
|
447
|
+
*/
|
|
448
|
+
releaseMaterialized(path) {
|
|
449
|
+
const cached = this.materialized.get(path);
|
|
450
|
+
if (!cached)
|
|
451
|
+
return;
|
|
452
|
+
this.materialized.delete(path);
|
|
453
|
+
cached.front.close();
|
|
454
|
+
}
|
|
455
|
+
/** The preferred entry on the most specific route covering `path`, ignoring skipped entries, if any. */
|
|
456
|
+
bestEntry(path, skip) {
|
|
457
|
+
let bestPrefix;
|
|
458
|
+
let best;
|
|
459
|
+
for (const [prefix, entries] of this.routes.peek() ?? []) {
|
|
460
|
+
if (!Path.hasPrefix(prefix, path))
|
|
461
|
+
continue;
|
|
462
|
+
const entry = preferredEntry(entries, (candidate) => !candidate.scope.matches(path) || (skip?.(candidate) ?? false));
|
|
463
|
+
if (!entry)
|
|
464
|
+
continue;
|
|
465
|
+
if (bestPrefix === undefined || prefix.length > bestPrefix.length) {
|
|
466
|
+
bestPrefix = prefix;
|
|
467
|
+
best = entry;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return best;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Whether the announced local broadcast at `path` wins over `entry`, the best route a
|
|
474
|
+
* session or dynamic handle announced there. Cost decides, as for any two routes: an
|
|
475
|
+
* identified route strictly cheaper than the local one wins, and the local broadcast
|
|
476
|
+
* wins a tie. A route at a shorter prefix never competes, since the most specific
|
|
477
|
+
* prefix wins outright. False when nothing is announced locally at `path`.
|
|
478
|
+
*/
|
|
479
|
+
localWins(path, entry) {
|
|
480
|
+
const local = this.advertisedLocal.peek()?.get(path);
|
|
481
|
+
if (!local || !this.local.peek()?.has(path))
|
|
482
|
+
return false;
|
|
483
|
+
if (!entry || !this.routes.peek()?.get(path)?.includes(entry))
|
|
484
|
+
return true;
|
|
485
|
+
return compareRoutes(local, entry.route.peek()) <= 0;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* What `path` resolves to: an announced local publish, a broadcast materialized from
|
|
489
|
+
* the best covering route, or the blind answer.
|
|
490
|
+
*
|
|
491
|
+
* Materialization is lazy and cached per path: the first request under a route opens
|
|
492
|
+
* the providing session's subscription and repeats share it. A better route is made
|
|
493
|
+
* before the old one breaks: the current front keeps serving until the new route
|
|
494
|
+
* answers (then swaps) or refuses (then is skipped). A retracted route swaps at once.
|
|
495
|
+
*/
|
|
496
|
+
route(path, slot) {
|
|
497
|
+
const entry = this.bestEntry(path, (candidate) => slot.refused.has(candidate));
|
|
498
|
+
const local = this.local.peek()?.get(path);
|
|
499
|
+
if (local && this.localWins(path, entry)) {
|
|
500
|
+
// Nothing reads a remote front the local broadcast replaced, so close its session subscription.
|
|
501
|
+
this.releaseMaterialized(path);
|
|
502
|
+
return local;
|
|
503
|
+
}
|
|
504
|
+
let cached = this.materialized.get(path);
|
|
505
|
+
if (cached && cached.front.closed.peek() !== undefined) {
|
|
506
|
+
this.materialized.delete(path);
|
|
507
|
+
cached = undefined;
|
|
508
|
+
}
|
|
509
|
+
if (cached && cached.entry === entry)
|
|
510
|
+
return cached.front;
|
|
511
|
+
if (!entry?.server) {
|
|
512
|
+
this.releaseMaterialized(path);
|
|
513
|
+
return slot.answer;
|
|
514
|
+
}
|
|
515
|
+
const served = entry.server.served.get(path);
|
|
516
|
+
if (served && served.closed.peek() === undefined) {
|
|
517
|
+
cached?.front.close();
|
|
518
|
+
this.materialized.set(path, { entry, front: served });
|
|
519
|
+
return served;
|
|
520
|
+
}
|
|
521
|
+
entry.server.enqueue(path);
|
|
522
|
+
return cached?.front;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The write side of an origin: create broadcasts by path and advertise them.
|
|
527
|
+
*
|
|
528
|
+
* Independent of any connection. A connection given this origin (via its `publish` option)
|
|
529
|
+
* announces and serves the table's originated advertisements for as long as the session
|
|
530
|
+
* lasts; the broadcasts themselves live until their producer closes or {@link close} tears
|
|
531
|
+
* the origin down. A reconnecting session re-announces the table on each attach, so
|
|
532
|
+
* advertisements made while offline surface on the next connection.
|
|
533
|
+
*
|
|
534
|
+
* Create, attach {@link dynamic} for tracks served on demand, populate, then
|
|
535
|
+
* {@link broadcast.Producer.announce}: an exact-path subscribe before the tracks exist is
|
|
536
|
+
* refused, and nobody can see or reach a broadcast until it announces.
|
|
537
|
+
*
|
|
538
|
+
* @public
|
|
539
|
+
*/
|
|
540
|
+
export class Producer {
|
|
541
|
+
#state = new OriginState();
|
|
542
|
+
#scope = Scope.all;
|
|
543
|
+
#requests;
|
|
544
|
+
// The reader backing the passthroughs, so holding a Producer never requires the
|
|
545
|
+
// consume().x() stutter for everyday reads. One instance, so `discovery` keeps its
|
|
546
|
+
// identity across reads.
|
|
547
|
+
#reader = makeConsumer(this.#state, this.#scope);
|
|
548
|
+
constructor() {
|
|
549
|
+
const thisProducer = this;
|
|
550
|
+
registerWire(this, {
|
|
551
|
+
receive: (prefix, route) => this.#receive(prefix, route),
|
|
552
|
+
interests: () => this.#scope.heads(),
|
|
553
|
+
accepts: (prefix) => !this.#scope.allowed ||
|
|
554
|
+
this.#scope.allowed.overlaps(Path.Pattern.subtree(Path.join(this.#scope.root, prefix))),
|
|
555
|
+
attach: (discovery) => this.#attach(discovery),
|
|
556
|
+
expect: () => this.#expect(),
|
|
557
|
+
get requests() {
|
|
558
|
+
if (thisProducer.#scope === Scope.all)
|
|
559
|
+
return thisProducer.#state.requests;
|
|
560
|
+
thisProducer.#requests ??= new Derived([thisProducer.#state.requests], (requests) => thisProducer.#scope.projectPaths(requests));
|
|
561
|
+
return thisProducer.#requests;
|
|
562
|
+
},
|
|
563
|
+
changed: () => this.#changed(),
|
|
564
|
+
answer: (path, front) => this.#answer(this.#scope.path(path), front),
|
|
565
|
+
routes: (path) => wireOf(this.#reader).routes(path),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** Narrow this handle to patterns beneath root, presenting paths relative to that root. */
|
|
569
|
+
scope(root, patterns) {
|
|
570
|
+
const scope = this.#scope.narrow(root, patterns);
|
|
571
|
+
const producer = new Producer();
|
|
572
|
+
producer.#state = this.#state;
|
|
573
|
+
producer.#scope = scope;
|
|
574
|
+
producer.#reader = makeConsumer(this.#state, scope);
|
|
575
|
+
return producer;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Settles once the origin closes: `null` on a clean close, or the abort {@link Error}.
|
|
579
|
+
* Peek it synchronously (`undefined` while open), observe it reactively, or `await` it.
|
|
580
|
+
*/
|
|
581
|
+
get closed() {
|
|
582
|
+
return this.#state.closed;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Create a broadcast at `path`, returning its producer.
|
|
586
|
+
*
|
|
587
|
+
* The broadcast exists for nobody until {@link broadcast.Producer.announce}: announce
|
|
588
|
+
* streams skip it and requests for its path find nothing, on this origin exactly as at
|
|
589
|
+
* a peer. Announce once its tracks exist; {@link broadcast.Producer.unannounce}
|
|
590
|
+
* withdraws it from everyone again.
|
|
591
|
+
*
|
|
592
|
+
* Close the producer to drop it. Creating a path again supersedes the previous
|
|
593
|
+
* broadcast: the origin drops its handle on the old one, which closes it unless the
|
|
594
|
+
* application still holds a consumer clone. An announced local broadcast competes with
|
|
595
|
+
* a remote route at the same path on cost, winning ties.
|
|
596
|
+
*/
|
|
597
|
+
createBroadcast(path) {
|
|
598
|
+
path = this.#scope.path(path);
|
|
599
|
+
const created = this.#state.created;
|
|
600
|
+
if (!created)
|
|
601
|
+
throw new Error("origin is closed");
|
|
602
|
+
const producer = new broadcast.Producer();
|
|
603
|
+
hooks.stampPath(producer, path);
|
|
604
|
+
const front = producer.consume();
|
|
605
|
+
hooks.attachAnnouncer(producer, {
|
|
606
|
+
announce: (route) => this.#advertiseExact(path, front, route),
|
|
607
|
+
unannounce: () => this.#retractExact(path, front),
|
|
608
|
+
});
|
|
609
|
+
const previous = created.get(path);
|
|
610
|
+
created.set(path, front);
|
|
611
|
+
if (previous) {
|
|
612
|
+
this.#retractExact(path, previous);
|
|
613
|
+
previous.close();
|
|
614
|
+
}
|
|
615
|
+
// Drop it when the broadcast closes, unless a recreate already replaced it: a
|
|
616
|
+
// stale broadcast closing must not unpublish the live one.
|
|
617
|
+
void front.closed.then(() => {
|
|
618
|
+
this.#retractExact(path, front);
|
|
619
|
+
if (this.#state.created?.get(path) === front)
|
|
620
|
+
this.#state.created.delete(path);
|
|
621
|
+
});
|
|
622
|
+
return producer;
|
|
623
|
+
}
|
|
624
|
+
#advertiseExact(path, front, route) {
|
|
625
|
+
if (!this.#state.local.peek())
|
|
626
|
+
throw new Error("origin is closed");
|
|
627
|
+
if (this.#state.created?.get(path) !== front)
|
|
628
|
+
throw new Error("broadcast is closed");
|
|
629
|
+
// Both maps move together, so every reader sees the broadcast and its route at once.
|
|
630
|
+
this.#state.local.mutate((broadcasts) => {
|
|
631
|
+
broadcasts?.set(path, front);
|
|
632
|
+
});
|
|
633
|
+
this.#state.advertisedLocal.mutate((advertised) => {
|
|
634
|
+
advertised?.set(path, route);
|
|
635
|
+
});
|
|
636
|
+
this.#state.rebuildOriginated();
|
|
637
|
+
this.#state.refresh(path);
|
|
638
|
+
}
|
|
639
|
+
#retractExact(path, front) {
|
|
640
|
+
if (this.#state.local.peek()?.get(path) !== front)
|
|
641
|
+
return;
|
|
642
|
+
this.#state.local.mutate((broadcasts) => {
|
|
643
|
+
broadcasts?.delete(path);
|
|
644
|
+
});
|
|
645
|
+
this.#state.advertisedLocal.mutate((advertised) => {
|
|
646
|
+
advertised?.delete(path);
|
|
647
|
+
});
|
|
648
|
+
this.#state.rebuildOriginated();
|
|
649
|
+
this.#state.refresh(path);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Advertise `prefix` and serve the requests beneath it.
|
|
653
|
+
*
|
|
654
|
+
* A route is always a prefix: it claims `prefix` and every path beneath it (the
|
|
655
|
+
* empty prefix claims every path). A service that only serves some of them
|
|
656
|
+
* advertises the covering prefix and rejects the rest as they are requested;
|
|
657
|
+
* consumers narrow with a {@link Path.Pattern} locally. The advertisement is
|
|
658
|
+
* visible to {@link Consumer.announced} and forwarded by sessions for as long as
|
|
659
|
+
* the returned {@link Dynamic} lives. A consumer resolving a path under it that
|
|
660
|
+
* no announced local broadcast wins is handed to the handle as a {@link Request}.
|
|
661
|
+
*/
|
|
662
|
+
dynamic(prefix, route = Route.default) {
|
|
663
|
+
return this.#insertRoute(prefix, Route.normalize(route), true);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Land a route a peer announced, served through the returned handle. Same as
|
|
667
|
+
* {@link dynamic} but not originated, so a session never announces it back.
|
|
668
|
+
*
|
|
669
|
+
* @internal
|
|
670
|
+
*/
|
|
671
|
+
#receive(prefix, route = Route.default) {
|
|
672
|
+
return this.#insertRoute(prefix, Route.normalize(route), false);
|
|
673
|
+
}
|
|
674
|
+
#insertRoute(prefix, route, originated) {
|
|
675
|
+
prefix = this.#scope.prefix(prefix);
|
|
676
|
+
const server = new ServeState(this.#scope.root);
|
|
677
|
+
server.onChange = (path) => this.#state.refresh(path);
|
|
678
|
+
const entry = {
|
|
679
|
+
identity: {},
|
|
680
|
+
scope: this.#scope,
|
|
681
|
+
claim: this.#scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])),
|
|
682
|
+
route: new Signal(route),
|
|
683
|
+
originated,
|
|
684
|
+
server,
|
|
685
|
+
};
|
|
686
|
+
server.onReject = (path, err) => this.#state.refuse(path, entry, err);
|
|
687
|
+
let closed = false;
|
|
688
|
+
this.#state.routes.mutate((routes) => {
|
|
689
|
+
if (!routes) {
|
|
690
|
+
closed = true;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const entries = routes.get(prefix);
|
|
694
|
+
if (entries)
|
|
695
|
+
entries.unshift(entry);
|
|
696
|
+
else
|
|
697
|
+
routes.set(prefix, [entry]);
|
|
698
|
+
});
|
|
699
|
+
if (closed) {
|
|
700
|
+
server.close();
|
|
701
|
+
return makeDynamic(prefix, entry, this.#state, () => { });
|
|
702
|
+
}
|
|
703
|
+
this.#state.rebuildOriginated();
|
|
704
|
+
this.#state.refreshPrefix(prefix);
|
|
705
|
+
const retract = () => {
|
|
706
|
+
this.#state.routes.mutate((routes) => {
|
|
707
|
+
const entries = routes?.get(prefix);
|
|
708
|
+
if (!entries)
|
|
709
|
+
return;
|
|
710
|
+
const index = entries.indexOf(entry);
|
|
711
|
+
if (index < 0)
|
|
712
|
+
return;
|
|
713
|
+
entries.splice(index, 1);
|
|
714
|
+
if (entries.length === 0)
|
|
715
|
+
routes?.delete(prefix);
|
|
716
|
+
});
|
|
717
|
+
// A retracted entry can never be picked again, so the refusals pinned to it are dead weight.
|
|
718
|
+
for (const slot of this.#state.requests.peek()?.values() ?? [])
|
|
719
|
+
slot.refused.delete(entry);
|
|
720
|
+
server.close();
|
|
721
|
+
this.#state.rebuildOriginated();
|
|
722
|
+
this.#state.refreshPrefix(prefix);
|
|
723
|
+
};
|
|
724
|
+
return makeDynamic(prefix, entry, this.#state, retract);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Register an attached session, counting it toward the `discovery` state. Returns the
|
|
728
|
+
* detach; call it exactly once when the session dies.
|
|
729
|
+
*
|
|
730
|
+
* @internal
|
|
731
|
+
*/
|
|
732
|
+
#attach(discovery) {
|
|
733
|
+
this.#sessions(1, discovery);
|
|
734
|
+
const release = this.#expect();
|
|
735
|
+
let detached = false;
|
|
736
|
+
return () => {
|
|
737
|
+
if (detached)
|
|
738
|
+
return;
|
|
739
|
+
detached = true;
|
|
740
|
+
this.#sessions(-1, discovery);
|
|
741
|
+
release();
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
#sessions(delta, discovery) {
|
|
745
|
+
this.#state.sessions.update(({ total, discovery: d }) => ({
|
|
746
|
+
total: total + delta,
|
|
747
|
+
discovery: d + (discovery ? delta : 0),
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Declare that something will answer requests on this origin, even with no session
|
|
752
|
+
* attached right now.
|
|
753
|
+
*
|
|
754
|
+
* A reconnecting connection holds one for its whole life, so a request made during a
|
|
755
|
+
* reconnect (or before the first session establishes) stays pending instead of reading as
|
|
756
|
+
* unroutable. Without it, {@link Request.unroutable} would fire on every page load, in the
|
|
757
|
+
* window between wiring the origin up and the handshake completing. Call the returned
|
|
758
|
+
* dispose when the connection is done for good.
|
|
759
|
+
*
|
|
760
|
+
* @internal
|
|
761
|
+
*/
|
|
762
|
+
#expect() {
|
|
763
|
+
this.#state.answerers.update((count) => count + 1);
|
|
764
|
+
let released = false;
|
|
765
|
+
return () => {
|
|
766
|
+
if (released)
|
|
767
|
+
return;
|
|
768
|
+
released = true;
|
|
769
|
+
// Clamped because closing the origin zeroes the count, and the sessions attached at
|
|
770
|
+
// the time still release afterwards.
|
|
771
|
+
this.#state.answerers.update((count) => Math.max(0, count - 1));
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Resolves once anything a serving session scans changes: the open requests, or either
|
|
776
|
+
* side of the routing table.
|
|
777
|
+
*
|
|
778
|
+
* @internal
|
|
779
|
+
*/
|
|
780
|
+
#changed() {
|
|
781
|
+
return Signal.race(this.#state.requests, this.#state.local, this.#state.routes, this.#state.advertisedLocal);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Provide `front` as the answer for the open request on `path`, taking ownership of it.
|
|
785
|
+
*
|
|
786
|
+
* Returns undefined (releasing the front) when the request is gone or already answered;
|
|
787
|
+
* first session in wins, and a loser must stay eligible to answer later. The returned
|
|
788
|
+
* withdraw releases the front and, if it was the standing answer, vacates the slot and
|
|
789
|
+
* wakes the other serving loops so a standby session answers immediately; call it when
|
|
790
|
+
* the session dies.
|
|
791
|
+
*
|
|
792
|
+
* @internal
|
|
793
|
+
*/
|
|
794
|
+
#answer(path, front) {
|
|
795
|
+
const slot = this.#state.requests.peek()?.get(path);
|
|
796
|
+
if (!slot || slot.answer !== undefined) {
|
|
797
|
+
front.close();
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
slot.answer = front;
|
|
801
|
+
this.#state.refresh(path);
|
|
802
|
+
return () => {
|
|
803
|
+
if (slot.answer === front) {
|
|
804
|
+
slot.answer = undefined;
|
|
805
|
+
this.#state.refresh(path);
|
|
806
|
+
// The route signal only reaches this path's requesters; poke the map so every
|
|
807
|
+
// serving loop re-scans and one of them re-answers.
|
|
808
|
+
this.#state.requests.mutate(() => { });
|
|
809
|
+
}
|
|
810
|
+
front.close();
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/** A read handle for this origin, the side a connection's `publish` option borrows. */
|
|
814
|
+
consume() {
|
|
815
|
+
return makeConsumer(this.#state, this.#scope);
|
|
816
|
+
}
|
|
817
|
+
/** Whether every attached session announces into the table; see {@link Consumer.discovery}. */
|
|
818
|
+
get discovery() {
|
|
819
|
+
return this.#reader.discovery;
|
|
820
|
+
}
|
|
821
|
+
/** Resolve `path`, optionally waiting for an announcement; see {@link Consumer.request}. */
|
|
822
|
+
request(path, options) {
|
|
823
|
+
return this.#reader.request(path, options);
|
|
824
|
+
}
|
|
825
|
+
/** The available announcements under `scope`, as a live map; see {@link Consumer.broadcasts}. */
|
|
826
|
+
broadcasts(scope, options) {
|
|
827
|
+
return this.#reader.broadcasts(scope, options);
|
|
828
|
+
}
|
|
829
|
+
/** The available broadcasts under `scope`, as a live stream; see {@link Consumer.announced}. */
|
|
830
|
+
announced(scope, options) {
|
|
831
|
+
return this.#reader.announced(scope, options);
|
|
832
|
+
}
|
|
833
|
+
/** Close the origin, every broadcast it still routes, and its announcement streams. Idempotent. */
|
|
834
|
+
close(abort) {
|
|
835
|
+
if (this.#state.closed.peek() !== undefined)
|
|
836
|
+
return;
|
|
837
|
+
this.#state.closed.set(abort ?? null);
|
|
838
|
+
for (const front of this.#state.created?.values() ?? []) {
|
|
839
|
+
front.close();
|
|
840
|
+
}
|
|
841
|
+
this.#state.created = undefined;
|
|
842
|
+
this.#state.local.update(() => undefined);
|
|
843
|
+
this.#state.advertisedLocal.update(() => undefined);
|
|
844
|
+
this.#state.routes.update((routes) => {
|
|
845
|
+
for (const entries of routes?.values() ?? []) {
|
|
846
|
+
for (const entry of entries)
|
|
847
|
+
entry.server?.close(abort);
|
|
848
|
+
}
|
|
849
|
+
return undefined;
|
|
850
|
+
});
|
|
851
|
+
this.#state.originated.update(() => undefined);
|
|
852
|
+
// Materialized broadcasts are handles we opened; release them.
|
|
853
|
+
for (const cached of this.#state.materialized.values()) {
|
|
854
|
+
cached.front.close();
|
|
855
|
+
}
|
|
856
|
+
this.#state.materialized.clear();
|
|
857
|
+
// Nothing will answer a request on a closed origin, whatever is still attached, so
|
|
858
|
+
// existing requests report unroutable rather than waiting on a corpse.
|
|
859
|
+
this.#state.answerers.set(0);
|
|
860
|
+
this.#state.requests.update((requests) => {
|
|
861
|
+
for (const slot of requests?.values() ?? []) {
|
|
862
|
+
slot.answer?.close();
|
|
863
|
+
slot.answer = undefined;
|
|
864
|
+
slot.route.set(undefined);
|
|
865
|
+
}
|
|
866
|
+
return undefined;
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Constructs a Consumer from within this module without exposing a public constructor
|
|
871
|
+
// that would leak the unexported OriginState. Assigned in the class's static block.
|
|
872
|
+
let makeConsumer;
|
|
873
|
+
// Same for Requesting: a public constructor would let a caller forge a handle that no origin
|
|
874
|
+
// ever registered, whose lifecycle guarantees are then false. `@internal` alone would not
|
|
875
|
+
// stop it, since the declaration emit keeps the constructor.
|
|
876
|
+
let makeRequesting;
|
|
877
|
+
let makeDynamic;
|
|
878
|
+
let makeRequest;
|
|
879
|
+
let finishRequest;
|
|
12
880
|
/**
|
|
13
|
-
*
|
|
881
|
+
* An open request for a path nothing announced; see {@link Consumer.request}.
|
|
14
882
|
*
|
|
15
|
-
*
|
|
16
|
-
* type system enforces "only validated origins flow into hop lists." Internal
|
|
17
|
-
* code that synthesizes an id (e.g. {@link randomOrigin}) uses
|
|
18
|
-
* `OriginSchema.parse(...)` to produce a branded value from the raw bigint.
|
|
883
|
+
* @public
|
|
19
884
|
*/
|
|
20
|
-
export
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
885
|
+
export class Requesting {
|
|
886
|
+
/** The requested path. */
|
|
887
|
+
path;
|
|
888
|
+
/**
|
|
889
|
+
* The resolved broadcast, or undefined while nothing provides the path.
|
|
890
|
+
*
|
|
891
|
+
* The table's route when it has one: a local publish (no round trip) or an announced
|
|
892
|
+
* broadcast, swapping when a republish takes the path. Otherwise a session's blind
|
|
893
|
+
* answer, which is assumed present rather than known live: a missing broadcast
|
|
894
|
+
* surfaces as a reset on the first track subscription, not here. Drops back to
|
|
895
|
+
* undefined when the providing route dies and resolves again when another appears.
|
|
896
|
+
*
|
|
897
|
+
* Yours for as long as the request is open: it is a handle of this request's own, so
|
|
898
|
+
* closing it ends your view of the path rather than the route everyone else reads.
|
|
899
|
+
* {@link close} releases whatever is current.
|
|
900
|
+
*/
|
|
901
|
+
active;
|
|
902
|
+
/**
|
|
903
|
+
* Whether nothing can serve this path, as opposed to not having served it yet.
|
|
904
|
+
*
|
|
905
|
+
* True when the origin routes nothing here and nothing is prepared to answer: no session
|
|
906
|
+
* attached and no connection reconnecting toward one. False whenever {@link active} is
|
|
907
|
+
* set, and false while a connection is still coming up, so the ordinary page-load window
|
|
908
|
+
* before the first handshake reads as pending rather than as a missing broadcast. Waiting
|
|
909
|
+
* on this is futile by definition; wait for an announcement instead, via the origin's
|
|
910
|
+
* `announced`. True once the request is refused.
|
|
911
|
+
*/
|
|
912
|
+
unroutable;
|
|
913
|
+
/**
|
|
914
|
+
* Settles with the error a route's handler refused the path with, or `null` once you
|
|
915
|
+
* {@link close} the request. A refusal is final: no other route is asked, and a fresh
|
|
916
|
+
* request is needed to try again.
|
|
917
|
+
*/
|
|
918
|
+
closed;
|
|
919
|
+
#dispose;
|
|
920
|
+
#disposed = false;
|
|
921
|
+
constructor(path, active, unroutable, closed, dispose) {
|
|
922
|
+
this.path = path;
|
|
923
|
+
this.active = active;
|
|
924
|
+
this.unroutable = unroutable;
|
|
925
|
+
this.closed = closed;
|
|
926
|
+
this.#dispose = dispose;
|
|
927
|
+
}
|
|
928
|
+
static {
|
|
929
|
+
makeRequesting = (path, active, unroutable, closed, dispose) => new Requesting(path, active, unroutable, closed, dispose);
|
|
930
|
+
}
|
|
931
|
+
/** Withdraw the request. The path stays routed for any other open request. Idempotent. */
|
|
932
|
+
close() {
|
|
933
|
+
if (this.#disposed)
|
|
934
|
+
return;
|
|
935
|
+
this.#disposed = true;
|
|
936
|
+
this.#dispose();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
24
939
|
/**
|
|
25
|
-
* The
|
|
940
|
+
* The read side of an origin: resolve broadcasts by path and watch what is available.
|
|
941
|
+
*
|
|
942
|
+
* Obtain one from {@link Producer.consume}. Pass it to a connection's `publish` option to
|
|
943
|
+
* serve the origin's local broadcasts to that peer; read it directly to consume anything
|
|
944
|
+
* the origin routes, locally published or discovered by a session.
|
|
26
945
|
*
|
|
27
|
-
*
|
|
28
|
-
* be 0, so it identifies nothing: it is never a loop, never a publisher two chains have
|
|
29
|
-
* in common, and never excluded from an advertisement.
|
|
946
|
+
* @public
|
|
30
947
|
*/
|
|
31
|
-
export
|
|
948
|
+
export class Consumer {
|
|
949
|
+
#state;
|
|
950
|
+
#scope;
|
|
951
|
+
constructor(state, scope) {
|
|
952
|
+
this.#state = state;
|
|
953
|
+
this.#scope = scope;
|
|
954
|
+
// True only when every attached session announces. One session that cannot means the
|
|
955
|
+
// table is an incomplete picture, so a consumer gated on it has to keep its blind
|
|
956
|
+
// fallback: the paths only that session carries never reach the table at all.
|
|
957
|
+
this.#discovery = new Derived([state.sessions], ({ total, discovery }) => total === 0 ? undefined : discovery === total);
|
|
958
|
+
registerWire(this, {
|
|
959
|
+
routes: (path) => this.#routes(scope.path(path)),
|
|
960
|
+
broadcasts: scope === Scope.all ? state.local : new Derived([state.local], (local) => scope.projectPaths(local)),
|
|
961
|
+
advertised: scope === Scope.all
|
|
962
|
+
? state.originated
|
|
963
|
+
: new Derived([state.originated], (routes) => scope.projectRoutes(routes)),
|
|
964
|
+
local: (path) => this.#local(scope.path(path)),
|
|
965
|
+
demand: (path) => this.#demand(scope.path(path)),
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
static {
|
|
969
|
+
makeConsumer = (state, scope) => new Consumer(state, scope);
|
|
970
|
+
}
|
|
971
|
+
/** Settles once the origin closes; see {@link Producer.closed}. */
|
|
972
|
+
get closed() {
|
|
973
|
+
return this.#state.closed;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Whether the announcement table sees everything the attached sessions can serve.
|
|
977
|
+
*
|
|
978
|
+
* Undefined while no session is attached (nothing is known yet), true when every attached
|
|
979
|
+
* session announces into the table, and false as soon as one does not, where
|
|
980
|
+
* {@link announced} cannot be complete and consumers should {@link request} paths instead
|
|
981
|
+
* of waiting. One blind session among several is still false: the paths only it carries
|
|
982
|
+
* never reach the table, so a consumer that trusted the gate would never see them.
|
|
983
|
+
*/
|
|
984
|
+
get discovery() {
|
|
985
|
+
return this.#discovery;
|
|
986
|
+
}
|
|
987
|
+
// Derived per access rather than cached: a lightweight mapped view over the session
|
|
988
|
+
// counts, avoiding a Computed's lifecycle.
|
|
989
|
+
#discovery;
|
|
990
|
+
/**
|
|
991
|
+
* Whether the table routes `path`, by an announced local publish or an announced
|
|
992
|
+
* route covering it.
|
|
993
|
+
*
|
|
994
|
+
* Availability, not a handle: {@link request} is the only way to consume by path. A
|
|
995
|
+
* request on a routed path resolves to that route and never to a blind answer, which is
|
|
996
|
+
* why a serving session leaves it alone.
|
|
997
|
+
*
|
|
998
|
+
* @internal
|
|
999
|
+
*/
|
|
1000
|
+
#routes(path) {
|
|
1001
|
+
if (this.#state.local.peek()?.has(path))
|
|
1002
|
+
return true;
|
|
1003
|
+
return this.#state.bestEntry(path) !== undefined;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve `path`, optionally waiting for an announcement.
|
|
1007
|
+
*
|
|
1008
|
+
* The one way to consume by path. {@link Requesting.active} follows whatever the table
|
|
1009
|
+
* routes (an announced local publish, or any feeding session's announcement, swapping
|
|
1010
|
+
* on a republish or a retraction); when nothing does, the request stands and whichever
|
|
1011
|
+
* attached session answers first provides a blind subscription instead, re-answered
|
|
1012
|
+
* across reconnects.
|
|
1013
|
+
* With `announced: true`, an unrouted request waits while discovery is supported and
|
|
1014
|
+
* falls back to that blind behavior only when discovery is unavailable. Close the request
|
|
1015
|
+
* when done. On a closed origin it never resolves.
|
|
1016
|
+
*
|
|
1017
|
+
* With several sessions on one origin the first to answer wins, and it may be one that
|
|
1018
|
+
* does not carry the path. Nothing corrects that: a missing broadcast surfaces as a reset
|
|
1019
|
+
* on the first track and deliberately leaves the handle open, since the wire cannot tell
|
|
1020
|
+
* "not here" from "not yet" and a blind handle is expected to survive until a publisher
|
|
1021
|
+
* arrives. It matters only on an origin mixing sessions that announce with sessions that
|
|
1022
|
+
* cannot, where a path only the silent session carries may sit behind another session's
|
|
1023
|
+
* answer. Prefer {@link unroutable} and announcements over blind requests when the origin
|
|
1024
|
+
* feeds from more than one connection.
|
|
1025
|
+
*/
|
|
1026
|
+
request(path, options = {}) {
|
|
1027
|
+
const relative = path;
|
|
1028
|
+
path = this.#scope.path(path);
|
|
1029
|
+
const requests = this.#state.requests.peek();
|
|
1030
|
+
if (!requests) {
|
|
1031
|
+
// Closed origin: a request that can never resolve, and says so.
|
|
1032
|
+
const closed = new Once();
|
|
1033
|
+
return makeRequesting(relative, new Signal(undefined), getter(true), closed, () => closed.set(null));
|
|
1034
|
+
}
|
|
1035
|
+
let slot = requests.get(path);
|
|
1036
|
+
if (!slot) {
|
|
1037
|
+
// Seeded through the constructor, so a path the table already routes resolves on the
|
|
1038
|
+
// first read. It must not go through a silent set: that still captures the pre-seed
|
|
1039
|
+
// value as the baseline the next change is compared against, and never flushes to
|
|
1040
|
+
// clear it, so a seeded route retracting to undefined would look like no change and
|
|
1041
|
+
// notify nobody.
|
|
1042
|
+
const refused = new Set();
|
|
1043
|
+
const created = {
|
|
1044
|
+
blind: 0,
|
|
1045
|
+
handles: new Set(),
|
|
1046
|
+
refused,
|
|
1047
|
+
route: new Signal(this.#state.route(path, { refused })),
|
|
1048
|
+
};
|
|
1049
|
+
slot = created;
|
|
1050
|
+
this.#state.requests.mutate((map) => {
|
|
1051
|
+
map?.set(path, created);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
const closed = new Once();
|
|
1055
|
+
slot.handles.add(closed);
|
|
1056
|
+
let blind = !options.announced || this.#discovery.peek() === false;
|
|
1057
|
+
if (blind)
|
|
1058
|
+
slot.blind += 1;
|
|
1059
|
+
this.#state.requests.mutate(() => { });
|
|
1060
|
+
// An announcement-gated request falls back to a blind subscription only while at
|
|
1061
|
+
// least one attached session cannot announce. It returns to the gate if discovery
|
|
1062
|
+
// becomes complete again, and remains gated with no session attached.
|
|
1063
|
+
const unsubscribeDiscovery = options.announced
|
|
1064
|
+
? this.#discovery.subscribe((discovery) => {
|
|
1065
|
+
const next = discovery === false;
|
|
1066
|
+
if (next === blind)
|
|
1067
|
+
return;
|
|
1068
|
+
blind = next;
|
|
1069
|
+
slot.blind += next ? 1 : -1;
|
|
1070
|
+
this.#state.requests.mutate(() => { });
|
|
1071
|
+
})
|
|
1072
|
+
: () => { };
|
|
1073
|
+
// Hand out a handle of the request's own rather than the table's. Closing a consumer
|
|
1074
|
+
// closes the broadcast once it was the last one, and the table often holds the only
|
|
1075
|
+
// other handle, so lending its front out means an ordinary close() by one requester
|
|
1076
|
+
// can unpublish the path for everybody else.
|
|
1077
|
+
const taken = slot;
|
|
1078
|
+
// Memoized on the route's identity: the same front resolving again returns the handle
|
|
1079
|
+
// we already made, and only a real swap clones a new one (cloning before closing the
|
|
1080
|
+
// old, so a broadcast that both routes share never briefly loses its last handle).
|
|
1081
|
+
let released = false;
|
|
1082
|
+
let source;
|
|
1083
|
+
let handle;
|
|
1084
|
+
const own = (front) => {
|
|
1085
|
+
if (released)
|
|
1086
|
+
return undefined;
|
|
1087
|
+
if (front !== source) {
|
|
1088
|
+
const previous = handle;
|
|
1089
|
+
source = front;
|
|
1090
|
+
handle = front?.clone();
|
|
1091
|
+
if (handle)
|
|
1092
|
+
hooks.stampPath(handle, relative);
|
|
1093
|
+
previous?.close();
|
|
1094
|
+
}
|
|
1095
|
+
return handle;
|
|
1096
|
+
};
|
|
1097
|
+
const route = taken.route;
|
|
1098
|
+
const active = new Derived([route], own);
|
|
1099
|
+
// Swapping on the read is what keeps a routed path resolving synchronously, but a
|
|
1100
|
+
// holder that only ever peeked would then pin a route that has already been retracted
|
|
1101
|
+
// until it happened to read again. Following the route as well retires it promptly,
|
|
1102
|
+
// and the memo makes the two paths agree: whichever runs first does the swap.
|
|
1103
|
+
const unsubscribe = route.subscribe(own);
|
|
1104
|
+
// Only meaningful while nothing is routed, so it reads the route rather than `active`:
|
|
1105
|
+
// the two cannot disagree, since a routed path always has an answerer-independent
|
|
1106
|
+
// answer.
|
|
1107
|
+
const unroutable = new Derived([route, this.#state.answerers, closed], (front, answerers, ended) => ended !== undefined || (!front && answerers === 0));
|
|
1108
|
+
return makeRequesting(relative, active, unroutable, closed, () => {
|
|
1109
|
+
// Releases this request's handle; the route itself belongs to the table.
|
|
1110
|
+
released = true;
|
|
1111
|
+
unsubscribeDiscovery();
|
|
1112
|
+
unsubscribe();
|
|
1113
|
+
handle?.close();
|
|
1114
|
+
handle = undefined;
|
|
1115
|
+
source = undefined;
|
|
1116
|
+
taken.handles.delete(closed);
|
|
1117
|
+
if (closed.peek() === undefined)
|
|
1118
|
+
closed.set(null);
|
|
1119
|
+
if (blind)
|
|
1120
|
+
taken.blind -= 1;
|
|
1121
|
+
this.#state.requests.mutate(() => { });
|
|
1122
|
+
if (taken.handles.size > 0)
|
|
1123
|
+
return;
|
|
1124
|
+
// Defer the teardown a microtask: an effect whose rerun was triggered by the
|
|
1125
|
+
// answer resolving closes its old request and takes a new one in the same tick,
|
|
1126
|
+
// and tearing down in between would drop the answer it is about to read.
|
|
1127
|
+
queueMicrotask(() => {
|
|
1128
|
+
if (taken.handles.size > 0)
|
|
1129
|
+
return;
|
|
1130
|
+
// A refused slot already tore itself down, and the path may hold a newer one.
|
|
1131
|
+
if (this.#state.requests.peek()?.get(path) !== taken)
|
|
1132
|
+
return;
|
|
1133
|
+
this.#state.requests.mutate((map) => {
|
|
1134
|
+
map?.delete(path);
|
|
1135
|
+
});
|
|
1136
|
+
taken.answer?.close();
|
|
1137
|
+
taken.answer = undefined;
|
|
1138
|
+
taken.route.set(undefined);
|
|
1139
|
+
this.#state.releaseMaterialized(path);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The announced routes matching `scope`, as a live map from covered prefix to route.
|
|
1145
|
+
* Local broadcasts appear once announced; received and dynamic routes retain their
|
|
1146
|
+
* advertised prefixes. Reads are synchronous, and the getter needs no teardown.
|
|
1147
|
+
* Hidden routes are left out unless `options.hidden` opts in (see {@link announce.Options}).
|
|
1148
|
+
* Unscoped readers share one snapshot; each distinct scope filters the table on changes.
|
|
1149
|
+
*/
|
|
1150
|
+
broadcasts(scope, options) {
|
|
1151
|
+
const hidden = options?.hidden ?? false;
|
|
1152
|
+
if (!scope && this.#scope === Scope.all)
|
|
1153
|
+
return hidden ? this.#state.available : this.#state.visible;
|
|
1154
|
+
const patterns = this.#scope.patterns(scope);
|
|
1155
|
+
return new Derived([this.#state.available], () => {
|
|
1156
|
+
const routes = new Map();
|
|
1157
|
+
for (const [path, entry] of this.#listed(patterns, hidden))
|
|
1158
|
+
routes.set(path, entry.route);
|
|
1159
|
+
return routes;
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* The announced routes matching `scope`, as a live stream: every currently advertised
|
|
1164
|
+
* route arrives first as active, then additions and retractions as they happen.
|
|
1165
|
+
* Any pattern is accepted. A local broadcast appears once it announces, exactly as a
|
|
1166
|
+
* peer sees it. A dynamic or received route announces the prefix it covers when its
|
|
1167
|
+
* subtree overlaps the scope. The stream ends when the origin closes or the consumer is
|
|
1168
|
+
* closed. Hidden routes are left out unless `options.hidden` opts in (see {@link announce.Options}).
|
|
1169
|
+
*/
|
|
1170
|
+
announced(scope = Path.Pattern.all(), options) {
|
|
1171
|
+
const producer = new announce.Producer();
|
|
1172
|
+
void this.#runAnnounced(producer, this.#scope.patterns(scope), options?.hidden ?? false);
|
|
1173
|
+
return producer.consume();
|
|
1174
|
+
}
|
|
1175
|
+
/** One snapshot shared by map readers and announcement-stream diffing. */
|
|
1176
|
+
#listed(patterns, hidden) {
|
|
1177
|
+
const next = new Map();
|
|
1178
|
+
const covering = new CoveringRoot(this.#scope.root);
|
|
1179
|
+
const scopes = [...patterns]
|
|
1180
|
+
.sort((a, b) => Path.compareSpecificity(b.specificity(), a.specificity()))
|
|
1181
|
+
.map((pattern) => ({ pattern, head: scopeHead(pattern) }));
|
|
1182
|
+
for (const [path, candidates] of this.#state.snapshot().candidates) {
|
|
1183
|
+
// The first candidate this reader can see wins, since the preferred one overall may not be.
|
|
1184
|
+
let entry;
|
|
1185
|
+
let scope;
|
|
1186
|
+
for (const candidate of candidates) {
|
|
1187
|
+
scope = scopes.find(({ pattern, head }) => (candidate.exact ? pattern.matches(path) : advertOverlaps(candidate, path, pattern)) &&
|
|
1188
|
+
(hidden || !hiddenBelow(head, path)));
|
|
1189
|
+
if (scope) {
|
|
1190
|
+
entry = candidate;
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (!entry || !scope)
|
|
1195
|
+
continue;
|
|
1196
|
+
const relative = covering.relative(path);
|
|
1197
|
+
if (relative === undefined)
|
|
1198
|
+
continue;
|
|
1199
|
+
next.set(relative, {
|
|
1200
|
+
identity: entry.identity,
|
|
1201
|
+
route: entry.route,
|
|
1202
|
+
captures: scopeCaptures(scope.pattern, path),
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return next;
|
|
1206
|
+
}
|
|
1207
|
+
async #runAnnounced(producer, patterns, hidden) {
|
|
1208
|
+
// Keyed by the presented path (from the origin, not the scope), valued by identity
|
|
1209
|
+
// plus route. Diffing identity rather than mere presence means a republish emits a
|
|
1210
|
+
// retraction then a fresh announcement; a re-price of the same identity emits an
|
|
1211
|
+
// update.
|
|
1212
|
+
let active = new Map();
|
|
1213
|
+
try {
|
|
1214
|
+
for (;;) {
|
|
1215
|
+
const local = this.#state.local.peek();
|
|
1216
|
+
const advertisedLocal = this.#state.advertisedLocal.peek();
|
|
1217
|
+
const routes = this.#state.routes.peek();
|
|
1218
|
+
if (local === undefined && advertisedLocal === undefined && routes === undefined)
|
|
1219
|
+
break;
|
|
1220
|
+
const next = this.#listed(patterns, hidden);
|
|
1221
|
+
for (const [path, snap] of active) {
|
|
1222
|
+
const cur = next.get(path);
|
|
1223
|
+
if (!cur || cur.identity !== snap.identity)
|
|
1224
|
+
producer.append({
|
|
1225
|
+
prefix: path,
|
|
1226
|
+
captures: snap.captures,
|
|
1227
|
+
kind: "retracted",
|
|
1228
|
+
route: snap.route,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
for (const [path, snap] of next) {
|
|
1232
|
+
const prev = active.get(path);
|
|
1233
|
+
if (!prev || prev.identity !== snap.identity) {
|
|
1234
|
+
producer.append({
|
|
1235
|
+
prefix: path,
|
|
1236
|
+
captures: snap.captures,
|
|
1237
|
+
kind: "announced",
|
|
1238
|
+
route: snap.route,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
else if (!routesEqual(prev.route, snap.route)) {
|
|
1242
|
+
producer.append({ prefix: path, captures: snap.captures, kind: "updated", route: snap.route });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
active = next;
|
|
1246
|
+
await Signal.race(this.#state.local, this.#state.advertisedLocal, this.#state.routes, producer.closed);
|
|
1247
|
+
if (producer.closed.peek() !== undefined)
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
// The reader closed between the check and an append; nothing left to do.
|
|
1253
|
+
}
|
|
1254
|
+
producer.close();
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* The local table, borrowed by the wire publishers to answer subscribes.
|
|
1258
|
+
*
|
|
1259
|
+
* Deliberately excludes received routes: a session never re-announces what a peer
|
|
1260
|
+
* told it, so an origin wired to both directions of a connection cannot echo.
|
|
1261
|
+
* Borrowed, not owned: do not close the fronts. Undefined once the origin closes.
|
|
1262
|
+
*
|
|
1263
|
+
* @internal
|
|
1264
|
+
*/
|
|
1265
|
+
/**
|
|
1266
|
+
* Originated advertisements a session should forward: exact-path announces plus
|
|
1267
|
+
* originated dynamics. Undefined once the origin closes.
|
|
1268
|
+
*
|
|
1269
|
+
* @internal
|
|
1270
|
+
*/
|
|
1271
|
+
/**
|
|
1272
|
+
* The announced local broadcast at `path`, when it beats the originated routes there.
|
|
1273
|
+
* Resolves through what rebuildOriginated advertised: a peer never sees received routes.
|
|
1274
|
+
*/
|
|
1275
|
+
#local(path) {
|
|
1276
|
+
const local = this.#state.local.peek()?.get(path);
|
|
1277
|
+
if (local && this.#state.localWins(path, this.#state.bestEntry(path, received)))
|
|
1278
|
+
return local;
|
|
1279
|
+
return undefined;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Resolve `path` for serving: an announced local broadcast, or wait for an originated
|
|
1283
|
+
* dynamic to accept it. Undefined when nothing here can serve the path.
|
|
1284
|
+
*
|
|
1285
|
+
* @internal
|
|
1286
|
+
*/
|
|
1287
|
+
async #demand(path) {
|
|
1288
|
+
const local = this.#local(path);
|
|
1289
|
+
if (local)
|
|
1290
|
+
return local;
|
|
1291
|
+
const entry = this.#state.bestEntry(path, received);
|
|
1292
|
+
if (!entry?.server)
|
|
1293
|
+
return undefined;
|
|
1294
|
+
const server = entry.server;
|
|
1295
|
+
const live = server.served.get(path);
|
|
1296
|
+
if (live && live.closed.peek() === undefined)
|
|
1297
|
+
return live;
|
|
1298
|
+
server.enqueue(path);
|
|
1299
|
+
server.demanding.set(path, (server.demanding.get(path) ?? 0) + 1);
|
|
1300
|
+
try {
|
|
1301
|
+
for (;;) {
|
|
1302
|
+
const served = server.served.get(path);
|
|
1303
|
+
if (served && served.closed.peek() === undefined)
|
|
1304
|
+
return served;
|
|
1305
|
+
const rejected = server.rejected.get(path);
|
|
1306
|
+
if (rejected) {
|
|
1307
|
+
server.rejected.delete(path);
|
|
1308
|
+
throw rejected;
|
|
1309
|
+
}
|
|
1310
|
+
const closed = server.closed.peek();
|
|
1311
|
+
if (closed !== undefined) {
|
|
1312
|
+
if (closed)
|
|
1313
|
+
throw closed;
|
|
1314
|
+
return undefined;
|
|
1315
|
+
}
|
|
1316
|
+
if (!server.pending.has(path))
|
|
1317
|
+
return undefined;
|
|
1318
|
+
await Signal.race(server.settled, server.closed);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
finally {
|
|
1322
|
+
const n = (server.demanding.get(path) ?? 1) - 1;
|
|
1323
|
+
if (n <= 0)
|
|
1324
|
+
server.demanding.delete(path);
|
|
1325
|
+
else
|
|
1326
|
+
server.demanding.set(path, n);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
32
1330
|
/**
|
|
33
|
-
*
|
|
1331
|
+
* A served route from {@link Producer.dynamic}: advertises a prefix and answers the
|
|
1332
|
+
* requests beneath it.
|
|
1333
|
+
*
|
|
1334
|
+
* Drop it (or {@link close}) to retract the route and reject anything still waiting
|
|
1335
|
+
* with {@link StreamCode.NoCapacity}. {@link update} re-prices it in place.
|
|
34
1336
|
*
|
|
35
|
-
*
|
|
36
|
-
* pathological announcements across clusters with unbounded forwarding.
|
|
1337
|
+
* @public
|
|
37
1338
|
*/
|
|
38
|
-
export
|
|
1339
|
+
export class Dynamic {
|
|
1340
|
+
/** The prefix this handle advertises. */
|
|
1341
|
+
prefix;
|
|
1342
|
+
#entry;
|
|
1343
|
+
#state;
|
|
1344
|
+
#retract;
|
|
1345
|
+
#closed = false;
|
|
1346
|
+
constructor(prefix, entry, state, retract) {
|
|
1347
|
+
this.prefix = Path.stripPrefix(entry.scope.root, prefix) ?? Path.empty();
|
|
1348
|
+
this.#entry = entry;
|
|
1349
|
+
this.#state = state;
|
|
1350
|
+
this.#retract = retract;
|
|
1351
|
+
}
|
|
1352
|
+
static {
|
|
1353
|
+
makeDynamic = (prefix, entry, state, retract) => new Dynamic(prefix, entry, state, retract);
|
|
1354
|
+
}
|
|
1355
|
+
/** Re-price the route in place. The prefix is fixed at announce time. */
|
|
1356
|
+
update(route) {
|
|
1357
|
+
if (this.#closed)
|
|
1358
|
+
throw new Error("dynamic is closed");
|
|
1359
|
+
this.#entry.route.set(Route.normalize(route));
|
|
1360
|
+
this.#state.rebuildOriginated();
|
|
1361
|
+
this.#state.refreshPrefix(Path.join(this.#entry.scope.root, this.prefix));
|
|
1362
|
+
this.#state.routes.mutate(() => { });
|
|
1363
|
+
}
|
|
1364
|
+
/** Retract the route and reject anything still waiting. Idempotent. */
|
|
1365
|
+
close() {
|
|
1366
|
+
if (this.#closed)
|
|
1367
|
+
return;
|
|
1368
|
+
this.#closed = true;
|
|
1369
|
+
this.#retract();
|
|
1370
|
+
}
|
|
1371
|
+
/** Requests under this prefix, as they arrive, each to {@link Request.accept} or reject. */
|
|
1372
|
+
async *requested() {
|
|
1373
|
+
const server = this.#entry.server;
|
|
1374
|
+
if (!server)
|
|
1375
|
+
return;
|
|
1376
|
+
let current;
|
|
1377
|
+
const drop = () => {
|
|
1378
|
+
current?.reject(noCapacity());
|
|
1379
|
+
current = undefined;
|
|
1380
|
+
};
|
|
1381
|
+
try {
|
|
1382
|
+
for (;;) {
|
|
1383
|
+
const next = server.queue.peek()[0];
|
|
1384
|
+
if (next) {
|
|
1385
|
+
drop();
|
|
1386
|
+
server.queue.mutate((queue) => {
|
|
1387
|
+
queue.shift();
|
|
1388
|
+
});
|
|
1389
|
+
current = next;
|
|
1390
|
+
yield next;
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (server.closed.peek() !== undefined)
|
|
1394
|
+
return;
|
|
1395
|
+
await Signal.race(server.queue, server.closed);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
finally {
|
|
1399
|
+
drop();
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
39
1403
|
/**
|
|
40
|
-
*
|
|
1404
|
+
* A pending request for a broadcast to be served on demand.
|
|
41
1405
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
1406
|
+
* Yielded by {@link Dynamic.requested}. {@link accept} resolves it with a live
|
|
1407
|
+
* broadcast; {@link reject} resolves it with an error. Advancing the iterator or
|
|
1408
|
+
* closing it without either rejects the request.
|
|
44
1409
|
*
|
|
45
|
-
*
|
|
46
|
-
* decode `AnnounceInterest.exclude_hop` as a u53 (number) and throw on anything
|
|
47
|
-
* > 2^53-1. To keep those clients alive against fresh peers, we cap the random
|
|
48
|
-
* id at 53 bits. Restore to 62 bits once the u62 fix has propagated to deployed
|
|
49
|
-
* bundles. Mirrors `Origin::random` in rs/moq-net.
|
|
1410
|
+
* @public
|
|
50
1411
|
*/
|
|
51
|
-
export
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
1412
|
+
export class Request {
|
|
1413
|
+
/** The path that was requested. */
|
|
1414
|
+
path;
|
|
1415
|
+
#server;
|
|
1416
|
+
#done = false;
|
|
1417
|
+
constructor(path, server) {
|
|
1418
|
+
this.path = path;
|
|
1419
|
+
this.#server = server;
|
|
1420
|
+
}
|
|
1421
|
+
static {
|
|
1422
|
+
makeRequest = (path, server) => new Request(path, server);
|
|
1423
|
+
finishRequest = (request, err) => {
|
|
1424
|
+
request.#done = true;
|
|
1425
|
+
void err;
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Accept the request, resolving every awaiting requester with `broadcast`.
|
|
1430
|
+
*
|
|
1431
|
+
* The caller keeps producing into `broadcast`; repeat requests for the path share
|
|
1432
|
+
* it for as long as it stays live.
|
|
1433
|
+
*/
|
|
1434
|
+
accept(source) {
|
|
1435
|
+
if (this.#done)
|
|
1436
|
+
return;
|
|
1437
|
+
this.#done = true;
|
|
1438
|
+
const front = source instanceof broadcast.Producer ? source.consume() : source;
|
|
1439
|
+
this.#server.accept(this, front);
|
|
1440
|
+
}
|
|
1441
|
+
/** Reject the request, resolving every awaiting requester with `err`. */
|
|
1442
|
+
reject(err) {
|
|
1443
|
+
if (this.#done)
|
|
1444
|
+
return;
|
|
1445
|
+
this.#done = true;
|
|
1446
|
+
this.#server.reject(this, err);
|
|
1447
|
+
}
|
|
58
1448
|
}
|
|
59
1449
|
//# sourceMappingURL=origin.js.map
|