@norskvideo/moq-net 0.1.8 → 0.2.0

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.
Files changed (286) hide show
  1. package/README.md +2 -2
  2. package/announce.d.ts +7 -0
  3. package/announce.d.ts.map +1 -0
  4. package/announce.js +8 -0
  5. package/announce.js.map +1 -0
  6. package/announced.d.ts +49 -91
  7. package/announced.d.ts.map +1 -1
  8. package/announced.js +21 -156
  9. package/announced.js.map +1 -1
  10. package/bandwidth.d.ts +163 -0
  11. package/bandwidth.d.ts.map +1 -0
  12. package/bandwidth.js +304 -0
  13. package/bandwidth.js.map +1 -0
  14. package/bandwidth_api.d.ts +7 -0
  15. package/bandwidth_api.d.ts.map +1 -0
  16. package/bandwidth_api.js +8 -0
  17. package/bandwidth_api.js.map +1 -0
  18. package/broadcast.d.ts +44 -35
  19. package/broadcast.d.ts.map +1 -1
  20. package/broadcast.js +104 -60
  21. package/broadcast.js.map +1 -1
  22. package/connection/accept.d.ts +16 -1
  23. package/connection/accept.d.ts.map +1 -1
  24. package/connection/accept.js +52 -28
  25. package/connection/accept.js.map +1 -1
  26. package/connection/browser.d.ts.map +1 -1
  27. package/connection/browser.js +9 -7
  28. package/connection/browser.js.map +1 -1
  29. package/connection/connect.d.ts +30 -6
  30. package/connection/connect.d.ts.map +1 -1
  31. package/connection/connect.js +110 -54
  32. package/connection/connect.js.map +1 -1
  33. package/connection/established.d.ts +17 -21
  34. package/connection/established.d.ts.map +1 -1
  35. package/connection/established.js.map +1 -1
  36. package/connection/forward.d.ts +2 -0
  37. package/connection/forward.d.ts.map +1 -0
  38. package/connection/forward.js +173 -0
  39. package/connection/forward.js.map +1 -0
  40. package/connection/handshake.d.ts +1 -0
  41. package/connection/handshake.d.ts.map +1 -1
  42. package/connection/handshake.js +5 -2
  43. package/connection/handshake.js.map +1 -1
  44. package/connection/index.d.ts +5 -5
  45. package/connection/index.d.ts.map +1 -1
  46. package/connection/index.js +4 -5
  47. package/connection/index.js.map +1 -1
  48. package/connection/pool.d.ts +186 -0
  49. package/connection/pool.d.ts.map +1 -0
  50. package/connection/pool.js +361 -0
  51. package/connection/pool.js.map +1 -0
  52. package/connection/reload.d.ts +14 -93
  53. package/connection/reload.d.ts.map +1 -1
  54. package/connection/reload.js +217 -81
  55. package/connection/reload.js.map +1 -1
  56. package/connection/stats.d.ts +3 -26
  57. package/connection/stats.d.ts.map +1 -1
  58. package/connection/stats.js.map +1 -1
  59. package/connection/transport.d.ts +0 -7
  60. package/connection/transport.d.ts.map +1 -1
  61. package/consume.d.ts +1 -43
  62. package/consume.d.ts.map +1 -1
  63. package/consume.js +1 -1
  64. package/consume.js.map +1 -1
  65. package/error.d.ts +180 -29
  66. package/error.d.ts.map +1 -1
  67. package/error.js +331 -16
  68. package/error.js.map +1 -1
  69. package/errors.d.ts +7 -0
  70. package/errors.d.ts.map +1 -0
  71. package/errors.js +8 -0
  72. package/errors.js.map +1 -0
  73. package/group.d.ts +9 -43
  74. package/group.d.ts.map +1 -1
  75. package/group.js +284 -69
  76. package/group.js.map +1 -1
  77. package/hop.d.ts +115 -0
  78. package/hop.d.ts.map +1 -0
  79. package/hop.js +119 -0
  80. package/hop.js.map +1 -0
  81. package/ietf/adapter.d.ts +5 -1
  82. package/ietf/adapter.d.ts.map +1 -1
  83. package/ietf/adapter.js +105 -60
  84. package/ietf/adapter.js.map +1 -1
  85. package/ietf/aliases.d.ts +1 -78
  86. package/ietf/aliases.d.ts.map +1 -1
  87. package/ietf/cluster.d.ts +9 -123
  88. package/ietf/cluster.d.ts.map +1 -1
  89. package/ietf/cluster.js +84 -44
  90. package/ietf/cluster.js.map +1 -1
  91. package/ietf/connection.d.ts +6 -72
  92. package/ietf/connection.d.ts.map +1 -1
  93. package/ietf/connection.js +62 -56
  94. package/ietf/connection.js.map +1 -1
  95. package/ietf/error.d.ts +11 -0
  96. package/ietf/error.d.ts.map +1 -0
  97. package/ietf/error.js +193 -0
  98. package/ietf/error.js.map +1 -0
  99. package/ietf/fetch.d.ts +7 -20
  100. package/ietf/fetch.d.ts.map +1 -1
  101. package/ietf/fetch.js +52 -22
  102. package/ietf/fetch.js.map +1 -1
  103. package/ietf/filter.d.ts +2 -0
  104. package/ietf/filter.d.ts.map +1 -1
  105. package/ietf/filter.js +10 -1
  106. package/ietf/filter.js.map +1 -1
  107. package/ietf/goaway.d.ts.map +1 -1
  108. package/ietf/goaway.js +22 -5
  109. package/ietf/goaway.js.map +1 -1
  110. package/ietf/hidden.d.ts +2 -0
  111. package/ietf/hidden.d.ts.map +1 -0
  112. package/ietf/hidden.js +30 -0
  113. package/ietf/hidden.js.map +1 -0
  114. package/ietf/index.d.ts +2 -0
  115. package/ietf/index.d.ts.map +1 -1
  116. package/ietf/index.js +2 -0
  117. package/ietf/index.js.map +1 -1
  118. package/ietf/object.d.ts +15 -8
  119. package/ietf/object.d.ts.map +1 -1
  120. package/ietf/object.js +51 -36
  121. package/ietf/object.js.map +1 -1
  122. package/ietf/parameters.d.ts +14 -2
  123. package/ietf/parameters.d.ts.map +1 -1
  124. package/ietf/parameters.js +97 -29
  125. package/ietf/parameters.js.map +1 -1
  126. package/ietf/properties.d.ts +1 -0
  127. package/ietf/properties.d.ts.map +1 -1
  128. package/ietf/properties.js +14 -0
  129. package/ietf/properties.js.map +1 -1
  130. package/ietf/publish.d.ts +19 -2
  131. package/ietf/publish.d.ts.map +1 -1
  132. package/ietf/publish.js +40 -6
  133. package/ietf/publish.js.map +1 -1
  134. package/ietf/publish_namespace.d.ts +25 -0
  135. package/ietf/publish_namespace.d.ts.map +1 -1
  136. package/ietf/publish_namespace.js +63 -0
  137. package/ietf/publish_namespace.js.map +1 -1
  138. package/ietf/publisher.d.ts +1 -82
  139. package/ietf/publisher.d.ts.map +1 -1
  140. package/ietf/publisher.js +483 -235
  141. package/ietf/publisher.js.map +1 -1
  142. package/ietf/solicit.d.ts +1 -40
  143. package/ietf/solicit.d.ts.map +1 -1
  144. package/ietf/subscribe.d.ts +8 -6
  145. package/ietf/subscribe.d.ts.map +1 -1
  146. package/ietf/subscribe.js +33 -27
  147. package/ietf/subscribe.js.map +1 -1
  148. package/ietf/subscribe_namespace.d.ts +8 -2
  149. package/ietf/subscribe_namespace.d.ts.map +1 -1
  150. package/ietf/subscribe_namespace.js +18 -8
  151. package/ietf/subscribe_namespace.js.map +1 -1
  152. package/ietf/subscriber.d.ts +1 -65
  153. package/ietf/subscriber.d.ts.map +1 -1
  154. package/ietf/subscriber.js +337 -123
  155. package/ietf/subscriber.js.map +1 -1
  156. package/ietf/token.d.ts +2 -0
  157. package/ietf/token.d.ts.map +1 -0
  158. package/ietf/token.js +99 -0
  159. package/ietf/token.js.map +1 -0
  160. package/ietf/track.d.ts +4 -0
  161. package/ietf/track.d.ts.map +1 -1
  162. package/ietf/track.js +6 -20
  163. package/ietf/track.js.map +1 -1
  164. package/ietf/version.d.ts +12 -1
  165. package/ietf/version.d.ts.map +1 -1
  166. package/ietf/version.js +13 -0
  167. package/ietf/version.js.map +1 -1
  168. package/index.d.ts +12 -7
  169. package/index.d.ts.map +1 -1
  170. package/index.js +10 -5
  171. package/index.js.map +1 -1
  172. package/internal.d.ts +115 -1
  173. package/internal.d.ts.map +1 -1
  174. package/internal.js +108 -0
  175. package/internal.js.map +1 -1
  176. package/lite/announce.d.ts +60 -10
  177. package/lite/announce.d.ts.map +1 -1
  178. package/lite/announce.js +176 -31
  179. package/lite/announce.js.map +1 -1
  180. package/lite/connection.d.ts +9 -59
  181. package/lite/connection.d.ts.map +1 -1
  182. package/lite/connection.js +34 -35
  183. package/lite/connection.js.map +1 -1
  184. package/lite/datagram.d.ts +3 -2
  185. package/lite/datagram.d.ts.map +1 -1
  186. package/lite/datagram.js +7 -8
  187. package/lite/datagram.js.map +1 -1
  188. package/lite/fetch.d.ts +15 -1
  189. package/lite/fetch.d.ts.map +1 -1
  190. package/lite/fetch.js +39 -7
  191. package/lite/fetch.js.map +1 -1
  192. package/lite/goaway.d.ts.map +1 -1
  193. package/lite/goaway.js +7 -1
  194. package/lite/goaway.js.map +1 -1
  195. package/lite/group.d.ts +30 -12
  196. package/lite/group.d.ts.map +1 -1
  197. package/lite/group.js +68 -26
  198. package/lite/group.js.map +1 -1
  199. package/lite/message.d.ts +2 -2
  200. package/lite/message.d.ts.map +1 -1
  201. package/lite/message.js +14 -5
  202. package/lite/message.js.map +1 -1
  203. package/lite/priority.d.ts +1 -61
  204. package/lite/priority.d.ts.map +1 -1
  205. package/lite/priority.js +4 -5
  206. package/lite/priority.js.map +1 -1
  207. package/lite/publisher.d.ts +1 -69
  208. package/lite/publisher.d.ts.map +1 -1
  209. package/lite/publisher.js +630 -262
  210. package/lite/publisher.js.map +1 -1
  211. package/lite/setup.d.ts +8 -8
  212. package/lite/setup.d.ts.map +1 -1
  213. package/lite/setup.js +33 -30
  214. package/lite/setup.js.map +1 -1
  215. package/lite/subscribe.d.ts +71 -17
  216. package/lite/subscribe.d.ts.map +1 -1
  217. package/lite/subscribe.js +205 -53
  218. package/lite/subscribe.js.map +1 -1
  219. package/lite/subscriber.d.ts +15 -56
  220. package/lite/subscriber.d.ts.map +1 -1
  221. package/lite/subscriber.js +451 -247
  222. package/lite/subscriber.js.map +1 -1
  223. package/lite/track.d.ts +4 -10
  224. package/lite/track.d.ts.map +1 -1
  225. package/lite/track.js +34 -29
  226. package/lite/track.js.map +1 -1
  227. package/lite/version.d.ts +42 -6
  228. package/lite/version.d.ts.map +1 -1
  229. package/lite/version.js +126 -10
  230. package/lite/version.js.map +1 -1
  231. package/origin.d.ts +256 -29
  232. package/origin.d.ts.map +1 -1
  233. package/origin.js +1427 -37
  234. package/origin.js.map +1 -1
  235. package/package.json +8 -3
  236. package/path.d.ts +25 -7
  237. package/path.d.ts.map +1 -1
  238. package/path.js +5 -3
  239. package/path.js.map +1 -1
  240. package/stream.d.ts +73 -14
  241. package/stream.d.ts.map +1 -1
  242. package/stream.js +372 -141
  243. package/stream.js.map +1 -1
  244. package/tail.d.ts +18 -0
  245. package/tail.d.ts.map +1 -0
  246. package/tail.js +167 -0
  247. package/tail.js.map +1 -0
  248. package/time.d.ts +15 -2
  249. package/time.d.ts.map +1 -1
  250. package/time.js +28 -9
  251. package/time.js.map +1 -1
  252. package/track.d.ts +211 -83
  253. package/track.d.ts.map +1 -1
  254. package/track.js +816 -205
  255. package/track.js.map +1 -1
  256. package/util/abort.d.ts +2 -0
  257. package/util/abort.d.ts.map +1 -0
  258. package/util/abort.js +20 -0
  259. package/util/abort.js.map +1 -0
  260. package/util/log.d.ts +5 -0
  261. package/util/log.d.ts.map +1 -0
  262. package/util/log.js +17 -0
  263. package/util/log.js.map +1 -0
  264. package/util/u64.d.ts +39 -0
  265. package/util/u64.d.ts.map +1 -0
  266. package/util/u64.js +83 -0
  267. package/util/u64.js.map +1 -0
  268. package/util/varint.d.ts +29 -0
  269. package/util/varint.d.ts.map +1 -0
  270. package/util/varint.js +198 -0
  271. package/util/varint.js.map +1 -0
  272. package/varint.d.ts +10 -6
  273. package/varint.d.ts.map +1 -1
  274. package/varint.js +40 -237
  275. package/varint.js.map +1 -1
  276. package/wire.d.ts +80 -0
  277. package/wire.d.ts.map +1 -0
  278. package/wire.js +32 -0
  279. package/wire.js.map +1 -0
  280. package/zod.d.ts +1 -1
  281. package/zod.d.ts.map +1 -1
  282. package/zod.js.map +1 -1
  283. package/mock.d.ts +0 -66
  284. package/mock.d.ts.map +0 -1
  285. package/mock.js +0 -243
  286. 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
- * Endpoint identity within a hop chain, shared by both wire protocols.
3
+ * A broadcast routing table, independent of any connection.
4
4
  *
5
- * moq-lite carries these natively on every announcement; moq-transport carries them
6
- * via the MoQ Cluster extension (see `ietf/cluster.ts`). Mirrors `Origin` in
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 * as z from "zod/mini";
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
- * A relay origin id, encoded as a 62-bit varint on the wire.
881
+ * An open request for a path nothing announced; see {@link Consumer.request}.
14
882
  *
15
- * The {@link OriginSchema} validates any incoming value and brands it so the
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 const OriginSchema = z
21
- .bigint()
22
- .check(z.refine((value) => value >= 0n && value < 1n << 62n, "Origin must be a non-negative 62-bit integer"))
23
- .brand("Origin");
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 reserved id 0, meaning "no identity".
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
- * It stands in for an endpoint that never declared one, and any number of endpoints can
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 const UNKNOWN_ORIGIN = OriginSchema.parse(0n);
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
- * Maximum length of a hop chain. Must match `MAX_HOPS` in Rust's `model/origin.rs`.
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
- * Broadcasts with longer chains are rejected, which bounds loop detection and rejects
36
- * pathological announcements across clusters with unbounded forwarding.
1337
+ * @public
37
1338
  */
38
- export const MAX_HOPS = 32;
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
- * Generate a fresh origin with a random non-zero id.
1404
+ * A pending request for a broadcast to be served on demand.
41
1405
  *
42
- * `crypto.getRandomValues` is overkill for best-effort loop detection, but
43
- * used for slightly better distribution than `Math.random` at negligible cost.
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
- * TEMPORARY: the wire format allows 62 bits, but older `@moq/lite` JS clients
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 function randomOrigin() {
52
- const buf = new BigUint64Array(1);
53
- crypto.getRandomValues(buf);
54
- // Mask to 53 bits.
55
- const raw = buf[0] & 0x1fffffffffffffn;
56
- // Guard against the (astronomically unlikely) zero draw.
57
- return OriginSchema.parse(raw === 0n ? 1n : raw);
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