@norskvideo/moq-json 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +13 -3
- package/index.d.ts.map +1 -1
- package/index.js +14 -3
- package/index.js.map +1 -1
- package/package.json +6 -5
- package/snapshot/consumer.d.ts +7 -5
- package/snapshot/consumer.d.ts.map +1 -1
- package/snapshot/consumer.js +31 -35
- package/snapshot/consumer.js.map +1 -1
- package/snapshot/decoder.d.ts +46 -0
- package/snapshot/decoder.d.ts.map +1 -0
- package/snapshot/decoder.js +76 -0
- package/snapshot/decoder.js.map +1 -0
- package/snapshot/encoder.d.ts +96 -0
- package/snapshot/encoder.d.ts.map +1 -0
- package/snapshot/encoder.js +196 -0
- package/snapshot/encoder.js.map +1 -0
- package/snapshot/index.d.ts +14 -1
- package/snapshot/index.d.ts.map +1 -1
- package/snapshot/index.js +13 -0
- package/snapshot/index.js.map +1 -1
- package/snapshot/producer.d.ts +22 -39
- package/snapshot/producer.d.ts.map +1 -1
- package/snapshot/producer.js +69 -185
- package/snapshot/producer.js.map +1 -1
- package/stream/consumer.d.ts +17 -0
- package/stream/consumer.d.ts.map +1 -0
- package/stream/consumer.js +46 -0
- package/stream/consumer.js.map +1 -0
- package/stream/decoder.d.ts +21 -0
- package/stream/decoder.d.ts.map +1 -0
- package/stream/decoder.js +28 -0
- package/stream/decoder.js.map +1 -0
- package/stream/encoder.d.ts +62 -0
- package/stream/encoder.d.ts.map +1 -0
- package/stream/encoder.js +78 -0
- package/stream/encoder.js.map +1 -0
- package/stream/index.d.ts +23 -0
- package/stream/index.d.ts.map +1 -0
- package/stream/index.js +24 -0
- package/stream/index.js.map +1 -0
- package/stream/producer.d.ts +18 -0
- package/stream/producer.d.ts.map +1 -0
- package/stream/producer.js +49 -0
- package/stream/producer.js.map +1 -0
- package/window/consumer.d.ts +26 -0
- package/window/consumer.d.ts.map +1 -0
- package/window/consumer.js +74 -0
- package/window/consumer.js.map +1 -0
- package/window/decoder.d.ts +57 -0
- package/window/decoder.d.ts.map +1 -0
- package/window/decoder.js +169 -0
- package/window/decoder.js.map +1 -0
- package/window/encoder.d.ts +72 -0
- package/window/encoder.d.ts.map +1 -0
- package/window/encoder.js +183 -0
- package/window/encoder.js.map +1 -0
- package/window/index.d.ts +39 -0
- package/window/index.d.ts.map +1 -0
- package/window/index.js +40 -0
- package/window/index.js.map +1 -0
- package/window/producer.d.ts +32 -0
- package/window/producer.d.ts.map +1 -0
- package/window/producer.js +90 -0
- package/window/producer.js.map +1 -0
- package/diff.test.d.ts +0 -2
- package/diff.test.d.ts.map +0 -1
- package/diff.test.js +0 -60
- package/diff.test.js.map +0 -1
- package/snapshot/compression.test.d.ts +0 -2
- package/snapshot/compression.test.d.ts.map +0 -1
- package/snapshot/compression.test.js +0 -132
- package/snapshot/compression.test.js.map +0 -1
- package/snapshot/producer.test.d.ts +0 -2
- package/snapshot/producer.test.d.ts.map +0 -1
- package/snapshot/producer.test.js +0 -75
- package/snapshot/producer.test.js.map +0 -1
- package/snapshot/snapshot.test.d.ts +0 -2
- package/snapshot/snapshot.test.d.ts.map +0 -1
- package/snapshot/snapshot.test.js +0 -185
- package/snapshot/snapshot.test.js.map +0 -1
- package/stream.d.ts +0 -54
- package/stream.d.ts.map +0 -1
- package/stream.js +0 -92
- package/stream.js.map +0 -1
- package/stream.test.d.ts +0 -2
- package/stream.test.d.ts.map +0 -1
- package/stream.test.js +0 -64
- package/stream.test.js.map +0 -1
- package/vectors.test.d.ts +0 -2
- package/vectors.test.d.ts.map +0 -1
- package/vectors.test.js +0 -18
- package/vectors.test.js.map +0 -1
package/snapshot/producer.js
CHANGED
|
@@ -1,106 +1,77 @@
|
|
|
1
1
|
/* @ts-self-types="./producer.d.ts" */
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { deepEqual, diff } from "../diff.js";
|
|
5
|
-
// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. Kept
|
|
6
|
-
// well below the per-group frame cap so a late joiner can always read the snapshot at frame 0.
|
|
7
|
-
const MAX_DELTA_FRAMES = 256;
|
|
8
|
-
// Delta ratio used when {@link Config.deltaRatio} is left unset.
|
|
9
|
-
const DEFAULT_DELTA_RATIO = 8;
|
|
2
|
+
import { Time } from "@norskvideo/moq-net";
|
|
3
|
+
import { DEFAULT_DELTA_RATIO, Encoder } from "./encoder.js";
|
|
10
4
|
/**
|
|
11
|
-
* Publishes a JSON value
|
|
5
|
+
* Publishes a JSON value over a track, choosing snapshots and deltas automatically.
|
|
12
6
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - **Without a track** (`new Producer(config)`): retains the value and fans it out to any number of
|
|
17
|
-
* subscription tracks attached with {@link serve}, seeding late joiners with the current value.
|
|
18
|
-
* This backs the hang catalog and is how an application publishes its own custom tracks.
|
|
7
|
+
* An {@link Encoder} that owns its track: it writes each encoded frame and rolls a group whenever
|
|
8
|
+
* the encoder emits a snapshot. When something else already owns the track, use the {@link Encoder}
|
|
9
|
+
* directly.
|
|
19
10
|
*/
|
|
20
11
|
export class Producer {
|
|
21
|
-
#config;
|
|
22
|
-
// Leaf mode: writes snapshots/deltas straight to a single track.
|
|
23
12
|
#track;
|
|
24
|
-
#group;
|
|
25
|
-
#last;
|
|
26
|
-
// Bytes of deltas already written to the current group, excluding the snapshot frame. Compressed
|
|
27
|
-
// frame sizes when compressing, raw otherwise, matching {@link #snapshotLen} so the budget check is
|
|
28
|
-
// like-for-like (and identical to the Rust producer).
|
|
29
|
-
#deltaBytes = 0;
|
|
30
|
-
// Size of the current group's snapshot frame, the reference the delta budget is measured against.
|
|
31
|
-
// Compressed when compressing, raw otherwise.
|
|
32
|
-
#snapshotLen = 0;
|
|
33
|
-
#groupFrames = 0;
|
|
34
|
-
// Group-scoped `deflate-raw` compression. `#encoder` is the current group's stream, swapped for a
|
|
35
|
-
// fresh one (cold window) at each snapshot, so a snapshot and its deltas share one DEFLATE stream.
|
|
36
|
-
#compress = false;
|
|
37
13
|
#encoder;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.#value = this.#config.initial;
|
|
50
|
-
}
|
|
51
|
-
this.#compress = this.#config.compression ?? false;
|
|
52
|
-
}
|
|
53
|
-
/** The current value, or `undefined` if nothing has been published yet. */
|
|
54
|
-
get value() {
|
|
55
|
-
return this.#track ? this.#last : this.#value;
|
|
14
|
+
#initial;
|
|
15
|
+
// The group a delta would be appended to, open only while deltas are enabled.
|
|
16
|
+
#group;
|
|
17
|
+
// Whether the encoder can emit deltas at all. With them off every frame is a snapshot, so a group
|
|
18
|
+
// is closed the moment it's written and never held open.
|
|
19
|
+
#deltas;
|
|
20
|
+
constructor(config) {
|
|
21
|
+
this.#track = config.track;
|
|
22
|
+
this.#encoder = new Encoder(config);
|
|
23
|
+
this.#initial = config.initial;
|
|
24
|
+
this.#deltas = (config.deltaRatio ?? DEFAULT_DELTA_RATIO) !== 0;
|
|
56
25
|
}
|
|
57
26
|
/** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */
|
|
58
27
|
update(value) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// failures so one broken track (e.g. closed mid-update) doesn't stop the others.
|
|
62
|
-
this.#value = value;
|
|
63
|
-
for (const output of this.#outputs ?? []) {
|
|
64
|
-
try {
|
|
65
|
-
output.update(value);
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
this.#outputs?.delete(output);
|
|
69
|
-
try {
|
|
70
|
-
output.finish();
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
// Already broken; nothing more to do.
|
|
74
|
-
}
|
|
75
|
-
console.warn("dropping failed json subscriber during fan-out", err);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
28
|
+
const frame = this.#encoder.update(value);
|
|
29
|
+
if (!frame)
|
|
78
30
|
return;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
31
|
+
// A throw here leaves the frame uncommitted, so the next update resynchronizes with a fresh
|
|
32
|
+
// snapshot. A delta against a snapshot no consumer ever saw would be unreadable.
|
|
33
|
+
this.#write(frame);
|
|
34
|
+
frame.commit();
|
|
35
|
+
}
|
|
36
|
+
#write(encoded) {
|
|
37
|
+
if (encoded.keyframe) {
|
|
38
|
+
// The previous group is complete; no more frames will be appended to it. Drop the handle
|
|
39
|
+
// before opening the next one, so a failure below doesn't leave a closed group behind.
|
|
40
|
+
this.#group?.close();
|
|
41
|
+
this.#group = undefined;
|
|
42
|
+
const group = this.#track.appendGroup();
|
|
43
|
+
try {
|
|
44
|
+
group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
// The group carries no frames, so close it rather than leaving it open on the track. A
|
|
48
|
+
// rejected frame doesn't close the track, and a consumer that already advanced into this
|
|
49
|
+
// group would otherwise block until some later update opened a newer one.
|
|
50
|
+
group.close();
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
if (this.#deltas) {
|
|
54
|
+
// Keep the group open so future deltas can be appended.
|
|
55
|
+
this.#group = group;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// Deltas disabled: one frame per group, identical to a plain JSON track.
|
|
59
|
+
group.close();
|
|
60
|
+
this.#group = undefined;
|
|
61
|
+
}
|
|
86
62
|
return;
|
|
87
|
-
const snapshot = new TextEncoder().encode(text);
|
|
88
|
-
const delta = this.#delta(json);
|
|
89
|
-
if (delta && this.#group) {
|
|
90
|
-
this.#deltaBytes += this.#writeDelta(this.#group, delta);
|
|
91
|
-
this.#groupFrames += 1;
|
|
92
63
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
this.#last = json;
|
|
64
|
+
if (!this.#group)
|
|
65
|
+
throw new Error("delta with no open group");
|
|
66
|
+
this.#group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() });
|
|
97
67
|
}
|
|
98
68
|
/**
|
|
99
69
|
* Mutate the current value in place and publish the result.
|
|
100
70
|
*
|
|
101
|
-
* The callback receives a deep clone of the
|
|
102
|
-
* {@link Config.initial} if nothing has been
|
|
103
|
-
* place; on return the result is published via {@link update}, a no-op
|
|
71
|
+
* The callback receives a deep clone of the current value: everything published through this
|
|
72
|
+
* producer so far, composed, falling back to {@link Config.initial} if nothing has been (throws if
|
|
73
|
+
* neither exists). Edit it in place; on return the result is published via {@link update}, a no-op
|
|
74
|
+
* if unchanged:
|
|
104
75
|
*
|
|
105
76
|
* ```ts
|
|
106
77
|
* producer.mutate((catalog) => {
|
|
@@ -111,11 +82,17 @@ export class Producer {
|
|
|
111
82
|
* Independent owners can share a single Producer and each edit only their own keys: every call
|
|
112
83
|
* starts from the latest value, so sections compose instead of clobbering one another. Use
|
|
113
84
|
* {@link update} to replace the whole value instead.
|
|
85
|
+
*
|
|
86
|
+
* After a rejected write the current value is what the producer last *tried* to publish, which
|
|
87
|
+
* consumers never received. That is deliberate: dropping a rejected owner's field would clobber it
|
|
88
|
+
* for whoever edits next, which is the failure this API exists to prevent. The owner whose write
|
|
89
|
+
* failed sees the error, and the next successful publish is a full snapshot carrying the composed
|
|
90
|
+
* value, so consumers converge on it either way.
|
|
114
91
|
*/
|
|
115
92
|
mutate(fn) {
|
|
116
93
|
// Start from the last-published value, falling back to the configured initial value. We
|
|
117
94
|
// don't invent an empty object: mutating with nothing to start from is a usage error.
|
|
118
|
-
const base =
|
|
95
|
+
const base = this.#encoder.value ?? this.#initial;
|
|
119
96
|
if (base === undefined) {
|
|
120
97
|
throw new Error("mutate() requires a prior update() or `initial` in the config");
|
|
121
98
|
}
|
|
@@ -123,108 +100,15 @@ export class Producer {
|
|
|
123
100
|
fn(value);
|
|
124
101
|
this.update(value);
|
|
125
102
|
}
|
|
126
|
-
/**
|
|
127
|
-
* Serve a subscription request: seed the track with the current value, then forward updates.
|
|
128
|
-
*
|
|
129
|
-
* Only available on a track-less (fan-out) producer. The subscriber is removed and finished when
|
|
130
|
-
* `effect` is cleaned up.
|
|
131
|
-
*
|
|
132
|
-
* Pass `opts.compression` to override the producer's configured compression for this subscriber
|
|
133
|
-
* only, so one fan-out producer can serve the same value both plaintext and `deflate-raw` (e.g.
|
|
134
|
-
* the catalog served on `catalog.json` and `catalog.json.z`).
|
|
135
|
-
*/
|
|
136
|
-
serve(track, effect, opts) {
|
|
137
|
-
if (!this.#outputs) {
|
|
138
|
-
throw new Error("serve() is only available on a track-less Producer");
|
|
139
|
-
}
|
|
140
|
-
const config = opts?.compression === undefined ? this.#config : { ...this.#config, compression: opts.compression };
|
|
141
|
-
const output = new Producer(track, config);
|
|
142
|
-
if (this.#value !== undefined)
|
|
143
|
-
output.update(this.#value);
|
|
144
|
-
this.#outputs.add(output);
|
|
145
|
-
effect.cleanup(() => {
|
|
146
|
-
this.#outputs?.delete(output);
|
|
147
|
-
output.finish();
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
/** Finish: close the track (leaf) or finish every subscriber (fan-out). */
|
|
103
|
+
/** Finish the track, closing any open group. */
|
|
151
104
|
finish() {
|
|
152
|
-
if (!this.#track) {
|
|
153
|
-
for (const output of this.#outputs ?? [])
|
|
154
|
-
output.finish();
|
|
155
|
-
this.#outputs?.clear();
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
105
|
this.#group?.close();
|
|
159
106
|
this.#group = undefined;
|
|
107
|
+
// The open group goes with the track, so the encoder must not keep emitting deltas into it.
|
|
108
|
+
// Any further update fails on the closed track, but it has to fail as a track error rather
|
|
109
|
+
// than as a delta with nowhere to put it.
|
|
110
|
+
this.#encoder.reset();
|
|
160
111
|
this.#track.close();
|
|
161
112
|
}
|
|
162
|
-
// Resolved delta ratio: the configured value, or the default when unset. `0` disables deltas.
|
|
163
|
-
get #deltaRatio() {
|
|
164
|
-
return this.#config.deltaRatio ?? DEFAULT_DELTA_RATIO;
|
|
165
|
-
}
|
|
166
|
-
// Build a delta frame, or `undefined` to signal that a fresh snapshot should be published.
|
|
167
|
-
//
|
|
168
|
-
// The budget gate runs first, against the deltas already written, so rolling a new group costs no
|
|
169
|
-
// merge-patch work. Since the gate excludes the frame about to be written, the delta that tips the
|
|
170
|
-
// group past `ratio * snapshot` still lands: a group overshoots the budget by at most one delta.
|
|
171
|
-
#delta(json) {
|
|
172
|
-
const ratio = this.#deltaRatio;
|
|
173
|
-
if (ratio === 0)
|
|
174
|
-
return undefined;
|
|
175
|
-
if (this.#last === undefined)
|
|
176
|
-
return undefined;
|
|
177
|
-
if (!this.#group || this.#groupFrames >= MAX_DELTA_FRAMES)
|
|
178
|
-
return undefined;
|
|
179
|
-
// Gate on the deltas accumulated so far (snapshot frame excluded), before computing the patch.
|
|
180
|
-
if (this.#deltaBytes > ratio * this.#snapshotLen)
|
|
181
|
-
return undefined;
|
|
182
|
-
const result = diff(this.#last, json);
|
|
183
|
-
if (result.forcedSnapshot)
|
|
184
|
-
return undefined;
|
|
185
|
-
return new TextEncoder().encode(JSON.stringify(result.patch));
|
|
186
|
-
}
|
|
187
|
-
#snapshot(track, snapshot) {
|
|
188
|
-
// The previous group is complete; no more frames will be appended to it.
|
|
189
|
-
this.#group?.close();
|
|
190
|
-
const group = track.appendGroup();
|
|
191
|
-
this.#snapshotLen = this.#writeSnapshot(group, snapshot);
|
|
192
|
-
this.#deltaBytes = 0;
|
|
193
|
-
this.#groupFrames = 1;
|
|
194
|
-
if (this.#deltaRatio !== 0) {
|
|
195
|
-
// Keep the group open so future deltas can be appended.
|
|
196
|
-
this.#group = group;
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
// Deltas disabled: one frame per group, identical to a plain JSON track.
|
|
200
|
-
group.close();
|
|
201
|
-
this.#group = undefined;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
// Write a group's snapshot (frame 0), returning the bytes written. On the compressed path this opens
|
|
205
|
-
// a fresh per-group encoder (cold window), so the snapshot and its deltas share one DEFLATE stream.
|
|
206
|
-
#writeSnapshot(group, frame) {
|
|
207
|
-
if (!this.#compress) {
|
|
208
|
-
group.writeFrame(frame);
|
|
209
|
-
return frame.length;
|
|
210
|
-
}
|
|
211
|
-
this.#encoder = new Encoder();
|
|
212
|
-
const slice = this.#encoder.frame(frame);
|
|
213
|
-
group.writeFrame(slice);
|
|
214
|
-
return slice.length;
|
|
215
|
-
}
|
|
216
|
-
// Write a delta frame, compressed against the current group's encoder when compressing. Returns the
|
|
217
|
-
// bytes written.
|
|
218
|
-
#writeDelta(group, frame) {
|
|
219
|
-
if (!this.#compress) {
|
|
220
|
-
group.writeFrame(frame);
|
|
221
|
-
return frame.length;
|
|
222
|
-
}
|
|
223
|
-
if (!this.#encoder)
|
|
224
|
-
throw new Error("compressed delta requires an open group");
|
|
225
|
-
const slice = this.#encoder.frame(frame);
|
|
226
|
-
group.writeFrame(slice);
|
|
227
|
-
return slice.length;
|
|
228
|
-
}
|
|
229
113
|
}
|
|
230
114
|
//# sourceMappingURL=producer.js.map
|
package/snapshot/producer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"producer.js","sourceRoot":"","sources":["../../src/snapshot/producer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAIhC,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAE7C,6FAA6F;AAC7F,+FAA+F;AAC/F,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B,iEAAiE;AACjE,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAgC9B;;;;;;;;;GASG;AACH,MAAM,OAAO,QAAQ;IACpB,OAAO,CAAY;IAEnB,iEAAiE;IACjE,MAAM,CAAa;IACnB,MAAM,CAAa;IACnB,KAAK,CAAW;IAChB,iGAAiG;IACjG,oGAAoG;IACpG,sDAAsD;IACtD,WAAW,GAAG,CAAC,CAAC;IAChB,kGAAkG;IAClG,8CAA8C;IAC9C,YAAY,GAAG,CAAC,CAAC;IACjB,YAAY,GAAG,CAAC,CAAC;IAEjB,kGAAkG;IAClG,mGAAmG;IACnG,SAAS,GAAG,KAAK,CAAC;IAClB,QAAQ,CAAW;IAEnB,qFAAqF;IACrF,QAAQ,CAAoB;IAC5B,MAAM,CAAK;IAMX,YAAY,aAAqC,EAAE,MAAM,GAAc,EAAE;QACxE,IAAI,aAAa,YAAY,GAAG,CAAC,KAAK,EAAE,CAAC;YACxC,IAAI,CAAC,MAAM,GAAG,aAAa,CAAC;YAC5B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACvB,CAAC;aAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,aAAa,IAAI,EAAE,CAAC;YACnC,IAAI,CAAC,QAAQ,GAAG,IAAI,GAAG,EAAE,CAAC;YAC1B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;QACpC,CAAC;QACD,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,IAAI,KAAK,CAAC;IACpD,CAAC;IAED,2EAA2E;IAC3E,IAAI,KAAK;QACR,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAE,IAAI,CAAC,KAAuB,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC;IAClE,CAAC;IAED,2FAA2F;IAC3F,MAAM,CAAC,KAAQ;QACd,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YAClB,uFAAuF;YACvF,iFAAiF;YACjF,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;YACpB,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,QAAQ,IAAI,EAAE,EAAE,CAAC;gBAC1C,IAAI,CAAC;oBACJ,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACtB,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACd,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;oBAC9B,IAAI,CAAC;wBACJ,MAAM,CAAC,MAAM,EAAE,CAAC;oBACjB,CAAC;oBAAC,MAAM,CAAC;wBACR,sCAAsC;oBACvC,CAAC;oBACD,OAAO,CAAC,IAAI,CAAC,gDAAgD,EAAE,GAAG,CAAC,CAAC;gBACrE,CAAC;YACF,CAAC;YACD,OAAO;QACR,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;QAE7E,sFAAsF;QACtF,kEAAkE;QAClE,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;QACnC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC9B,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,IAAI,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC;YAAE,OAAO;QAEpE,MAAM,QAAQ,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAChD,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YAC1B,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;YACzD,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC;QACxB,CAAC;aAAM,CAAC;YACP,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QACvC,CAAC;QAED,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACnB,CAAC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAsB;QAC5B,wFAAwF;QACxF,sFAAsF;QACtF,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;QAC9E,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;QAClF,CAAC;QAED,MAAM,KAAK,GAAG,eAAe,CAAC,IAAI,CAAM,CAAC;QACzC,EAAE,CAAC,KAAK,CAAC,CAAC;QACV,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpB,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,KAAgB,EAAE,MAAc,EAAE,IAAgC;QACvE,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;QACvE,CAAC;QAED,MAAM,MAAM,GACX,IAAI,EAAE,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC;QACrG,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAI,KAAK,EAAE,MAAM,CAAC,CAAC;QAC9C,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAE1D,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC1B,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;YACnB,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;YAC9B,MAAM,CAAC,MAAM,EAAE,CAAC;QACjB,CAAC,CAAC,CAAC;IACJ,CAAC;IAED,2EAA2E;IAC3E,MAAM;QACL,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YAClB,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,QAAQ,IAAI,EAAE;gBAAE,MAAM,CAAC,MAAM,EAAE,CAAC;YAC1D,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,CAAC;YACvB,OAAO;QACR,CAAC;QAED,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;QACrB,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;IAED,8FAA8F;IAC9F,IAAI,WAAW;QACd,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC;IACvD,CAAC;IAED,2FAA2F;IAC3F,EAAE;IACF,kGAAkG;IAClG,mGAAmG;IACnG,iGAAiG;IACjG,MAAM,CAAC,IAAa;QACnB,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC;QAC/B,IAAI,KAAK,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAClC,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;YAAE,OAAO,SAAS,CAAC;QAC/C,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,YAAY,IAAI,gBAAgB;YAAE,OAAO,SAAS,CAAC;QAE5E,+FAA+F;QAC/F,IAAI,IAAI,CAAC,WAAW,GAAG,KAAK,GAAG,IAAI,CAAC,YAAY;YAAE,OAAO,SAAS,CAAC;QAEnE,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACtC,IAAI,MAAM,CAAC,cAAc;YAAE,OAAO,SAAS,CAAC;QAE5C,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,SAAS,CAAC,KAAgB,EAAE,QAAoB;QAC/C,yEAAyE;QACzE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;QAErB,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAClC,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;QACzD,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QAEtB,IAAI,IAAI,CAAC,WAAW,KAAK,CAAC,EAAE,CAAC;YAC5B,wDAAwD;YACxD,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;QACrB,CAAC;aAAM,CAAC;YACP,yEAAyE;YACzE,KAAK,CAAC,KAAK,EAAE,CAAC;YACd,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;QACzB,CAAC;IACF,CAAC;IAED,qGAAqG;IACrG,oGAAoG;IACpG,cAAc,CAAC,KAAgB,EAAE,KAAiB;QACjD,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACrB,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,KAAK,CAAC,MAAM,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,QAAQ,GAAG,IAAI,OAAO,EAAE,CAAC;QAC9B,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACzC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QACxB,OAAO,KAAK,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,oGAAoG;IACpG,iBAAiB;IACjB,WAAW,CAAC,KAAgB,EAAE,KAAiB;QAC9C,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACrB,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,KAAK,CAAC,MAAM,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;QAC/E,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACzC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QACxB,OAAO,KAAK,CAAC,MAAM,CAAC;IACrB,CAAC;CACD","sourcesContent":["import { Encoder } from \"@moq/flate\";\nimport * as Moq from \"@moq/net\";\nimport type { Effect } from \"@moq/signals\";\nimport type * as z from \"zod/mini\";\n\nimport { deepEqual, diff } from \"../diff.ts\";\n\n// Maximum frames (snapshot + deltas) in a single group before a new snapshot is forced. Kept\n// well below the per-group frame cap so a late joiner can always read the snapshot at frame 0.\nconst MAX_DELTA_FRAMES = 256;\n\n// Delta ratio used when {@link Config.deltaRatio} is left unset.\nconst DEFAULT_DELTA_RATIO = 8;\n\nexport interface Config<T> {\n\t// Controls how aggressively the producer emits deltas (merge patches) instead of full snapshots.\n\t//\n\t// `0` disables deltas: every change is published as a new snapshot group.\n\t//\n\t// A positive number enables deltas: a new snapshot group is started once the deltas already written\n\t// to the current group (excluding the snapshot frame) exceed `deltaRatio` times the snapshot size.\n\t// The pending delta is excluded from that check, so the one that first crosses the budget still\n\t// lands before the group rolls. So `1` allows roughly one snapshot's worth of deltas before rolling.\n\t//\n\t// When {@link compression} is on, both sides of the comparison are measured on the compressed frame\n\t// sizes (the real wire cost).\n\t//\n\t// Defaults to `8` when unset.\n\tdeltaRatio?: number;\n\n\t// Optional zod schema used to validate each value before publishing.\n\tschema?: z.ZodMiniType<T>;\n\n\t// Starting value for {@link Producer.mutate} before anything has been published. Required to\n\t// mutate a producer that hasn't published yet (e.g. a fresh catalog); ignored once a value exists.\n\tinitial?: T;\n\n\t// Compress each group as one sync-flushed `deflate-raw` (RFC 1951) stream, so deltas reuse the\n\t// snapshot as context and shrink sharply. Interoperable with the Rust `moq-json` producer.\n\t// `false`/unset (the default) writes plaintext JSON frames. A {@link Consumer} reading the track\n\t// must set the same flag.\n\tcompression?: boolean;\n}\n\n/**\n * Publishes a JSON value as snapshots and deltas, chosen automatically.\n *\n * Construct it two ways:\n *\n * - **With a track** (`new Producer(track, config)`): writes directly to that one track.\n * - **Without a track** (`new Producer(config)`): retains the value and fans it out to any number of\n * subscription tracks attached with {@link serve}, seeding late joiners with the current value.\n * This backs the hang catalog and is how an application publishes its own custom tracks.\n */\nexport class Producer<T> {\n\t#config: Config<T>;\n\n\t// Leaf mode: writes snapshots/deltas straight to a single track.\n\t#track?: Moq.Track;\n\t#group?: Moq.Group;\n\t#last?: unknown;\n\t// Bytes of deltas already written to the current group, excluding the snapshot frame. Compressed\n\t// frame sizes when compressing, raw otherwise, matching {@link #snapshotLen} so the budget check is\n\t// like-for-like (and identical to the Rust producer).\n\t#deltaBytes = 0;\n\t// Size of the current group's snapshot frame, the reference the delta budget is measured against.\n\t// Compressed when compressing, raw otherwise.\n\t#snapshotLen = 0;\n\t#groupFrames = 0;\n\n\t// Group-scoped `deflate-raw` compression. `#encoder` is the current group's stream, swapped for a\n\t// fresh one (cold window) at each snapshot, so a snapshot and its deltas share one DEFLATE stream.\n\t#compress = false;\n\t#encoder?: Encoder;\n\n\t// Fan-out mode: retains the value and serves a child (leaf) Producer per subscriber.\n\t#outputs?: Set<Producer<T>>;\n\t#value?: T;\n\n\t/** Create a track-less, fan-out producer; attach subscribers with {@link serve}. */\n\tconstructor(config?: Config<T>);\n\t/** Create a producer that writes directly to `track`. */\n\tconstructor(track: Moq.Track, config?: Config<T>);\n\tconstructor(trackOrConfig?: Moq.Track | Config<T>, config: Config<T> = {}) {\n\t\tif (trackOrConfig instanceof Moq.Track) {\n\t\t\tthis.#track = trackOrConfig;\n\t\t\tthis.#config = config;\n\t\t} else {\n\t\t\tthis.#config = trackOrConfig ?? {};\n\t\t\tthis.#outputs = new Set();\n\t\t\tthis.#value = this.#config.initial;\n\t\t}\n\t\tthis.#compress = this.#config.compression ?? false;\n\t}\n\n\t/** The current value, or `undefined` if nothing has been published yet. */\n\tget value(): T | undefined {\n\t\treturn this.#track ? (this.#last as T | undefined) : this.#value;\n\t}\n\n\t/** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */\n\tupdate(value: T): void {\n\t\tif (!this.#track) {\n\t\t\t// Fan-out: retain the value and forward it to every subscriber. Isolate per-subscriber\n\t\t\t// failures so one broken track (e.g. closed mid-update) doesn't stop the others.\n\t\t\tthis.#value = value;\n\t\t\tfor (const output of this.#outputs ?? []) {\n\t\t\t\ttry {\n\t\t\t\t\toutput.update(value);\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.#outputs?.delete(output);\n\t\t\t\t\ttry {\n\t\t\t\t\t\toutput.finish();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Already broken; nothing more to do.\n\t\t\t\t\t}\n\t\t\t\t\tconsole.warn(\"dropping failed json subscriber during fan-out\", err);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst valid = this.#config.schema ? this.#config.schema.parse(value) : value;\n\n\t\t// Serialize once; parse it back to a normalized JSON value for diffing and comparison\n\t\t// (dropping `undefined` fields, matching what lands on the wire).\n\t\tconst text = JSON.stringify(valid);\n\t\tconst json = JSON.parse(text);\n\t\tif (this.#last !== undefined && deepEqual(this.#last, json)) return;\n\n\t\tconst snapshot = new TextEncoder().encode(text);\n\t\tconst delta = this.#delta(json);\n\t\tif (delta && this.#group) {\n\t\t\tthis.#deltaBytes += this.#writeDelta(this.#group, delta);\n\t\t\tthis.#groupFrames += 1;\n\t\t} else {\n\t\t\tthis.#snapshot(this.#track, snapshot);\n\t\t}\n\n\t\tthis.#last = json;\n\t}\n\n\t/**\n\t * Mutate the current value in place and publish the result.\n\t *\n\t * The callback receives a deep clone of the last-published value, falling back to\n\t * {@link Config.initial} if nothing has been published yet (throws if neither exists). Edit it in\n\t * place; on return the result is published via {@link update}, a no-op if unchanged:\n\t *\n\t * ```ts\n\t * producer.mutate((catalog) => {\n\t * \tcatalog.scte35 = { ... };\n\t * });\n\t * ```\n\t *\n\t * Independent owners can share a single Producer and each edit only their own keys: every call\n\t * starts from the latest value, so sections compose instead of clobbering one another. Use\n\t * {@link update} to replace the whole value instead.\n\t */\n\tmutate(fn: (value: T) => void): void {\n\t\t// Start from the last-published value, falling back to the configured initial value. We\n\t\t// don't invent an empty object: mutating with nothing to start from is a usage error.\n\t\tconst base = (this.#track ? this.#last : this.#value) ?? this.#config.initial;\n\t\tif (base === undefined) {\n\t\t\tthrow new Error(\"mutate() requires a prior update() or `initial` in the config\");\n\t\t}\n\n\t\tconst value = structuredClone(base) as T;\n\t\tfn(value);\n\t\tthis.update(value);\n\t}\n\n\t/**\n\t * Serve a subscription request: seed the track with the current value, then forward updates.\n\t *\n\t * Only available on a track-less (fan-out) producer. The subscriber is removed and finished when\n\t * `effect` is cleaned up.\n\t *\n\t * Pass `opts.compression` to override the producer's configured compression for this subscriber\n\t * only, so one fan-out producer can serve the same value both plaintext and `deflate-raw` (e.g.\n\t * the catalog served on `catalog.json` and `catalog.json.z`).\n\t */\n\tserve(track: Moq.Track, effect: Effect, opts?: { compression?: boolean }): void {\n\t\tif (!this.#outputs) {\n\t\t\tthrow new Error(\"serve() is only available on a track-less Producer\");\n\t\t}\n\n\t\tconst config =\n\t\t\topts?.compression === undefined ? this.#config : { ...this.#config, compression: opts.compression };\n\t\tconst output = new Producer<T>(track, config);\n\t\tif (this.#value !== undefined) output.update(this.#value);\n\n\t\tthis.#outputs.add(output);\n\t\teffect.cleanup(() => {\n\t\t\tthis.#outputs?.delete(output);\n\t\t\toutput.finish();\n\t\t});\n\t}\n\n\t/** Finish: close the track (leaf) or finish every subscriber (fan-out). */\n\tfinish(): void {\n\t\tif (!this.#track) {\n\t\t\tfor (const output of this.#outputs ?? []) output.finish();\n\t\t\tthis.#outputs?.clear();\n\t\t\treturn;\n\t\t}\n\n\t\tthis.#group?.close();\n\t\tthis.#group = undefined;\n\t\tthis.#track.close();\n\t}\n\n\t// Resolved delta ratio: the configured value, or the default when unset. `0` disables deltas.\n\tget #deltaRatio(): number {\n\t\treturn this.#config.deltaRatio ?? DEFAULT_DELTA_RATIO;\n\t}\n\n\t// Build a delta frame, or `undefined` to signal that a fresh snapshot should be published.\n\t//\n\t// The budget gate runs first, against the deltas already written, so rolling a new group costs no\n\t// merge-patch work. Since the gate excludes the frame about to be written, the delta that tips the\n\t// group past `ratio * snapshot` still lands: a group overshoots the budget by at most one delta.\n\t#delta(json: unknown): Uint8Array | undefined {\n\t\tconst ratio = this.#deltaRatio;\n\t\tif (ratio === 0) return undefined;\n\t\tif (this.#last === undefined) return undefined;\n\t\tif (!this.#group || this.#groupFrames >= MAX_DELTA_FRAMES) return undefined;\n\n\t\t// Gate on the deltas accumulated so far (snapshot frame excluded), before computing the patch.\n\t\tif (this.#deltaBytes > ratio * this.#snapshotLen) return undefined;\n\n\t\tconst result = diff(this.#last, json);\n\t\tif (result.forcedSnapshot) return undefined;\n\n\t\treturn new TextEncoder().encode(JSON.stringify(result.patch));\n\t}\n\n\t#snapshot(track: Moq.Track, snapshot: Uint8Array): void {\n\t\t// The previous group is complete; no more frames will be appended to it.\n\t\tthis.#group?.close();\n\n\t\tconst group = track.appendGroup();\n\t\tthis.#snapshotLen = this.#writeSnapshot(group, snapshot);\n\t\tthis.#deltaBytes = 0;\n\t\tthis.#groupFrames = 1;\n\n\t\tif (this.#deltaRatio !== 0) {\n\t\t\t// Keep the group open so future deltas can be appended.\n\t\t\tthis.#group = group;\n\t\t} else {\n\t\t\t// Deltas disabled: one frame per group, identical to a plain JSON track.\n\t\t\tgroup.close();\n\t\t\tthis.#group = undefined;\n\t\t}\n\t}\n\n\t// Write a group's snapshot (frame 0), returning the bytes written. On the compressed path this opens\n\t// a fresh per-group encoder (cold window), so the snapshot and its deltas share one DEFLATE stream.\n\t#writeSnapshot(group: Moq.Group, frame: Uint8Array): number {\n\t\tif (!this.#compress) {\n\t\t\tgroup.writeFrame(frame);\n\t\t\treturn frame.length;\n\t\t}\n\t\tthis.#encoder = new Encoder();\n\t\tconst slice = this.#encoder.frame(frame);\n\t\tgroup.writeFrame(slice);\n\t\treturn slice.length;\n\t}\n\n\t// Write a delta frame, compressed against the current group's encoder when compressing. Returns the\n\t// bytes written.\n\t#writeDelta(group: Moq.Group, frame: Uint8Array): number {\n\t\tif (!this.#compress) {\n\t\t\tgroup.writeFrame(frame);\n\t\t\treturn frame.length;\n\t\t}\n\t\tif (!this.#encoder) throw new Error(\"compressed delta requires an open group\");\n\t\tconst slice = this.#encoder.frame(frame);\n\t\tgroup.writeFrame(slice);\n\t\treturn slice.length;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"producer.js","sourceRoot":"","sources":["../../src/snapshot/producer.ts"],"names":[],"mappings":"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","sourcesContent":["import type * as Moq from \"@moq/net\";\nimport { Time } from \"@moq/net\";\n\nimport { type Config, DEFAULT_DELTA_RATIO, type Encoded, Encoder } from \"./encoder.ts\";\n\n/** Snapshot producer options, including the destination track. */\nexport type ProducerConfig<T> = Config<T> & { track: Moq.Track.Producer };\n\n/**\n * Publishes a JSON value over a track, choosing snapshots and deltas automatically.\n *\n * An {@link Encoder} that owns its track: it writes each encoded frame and rolls a group whenever\n * the encoder emits a snapshot. When something else already owns the track, use the {@link Encoder}\n * directly.\n */\nexport class Producer<T> {\n\t#track: Moq.Track.Producer;\n\t#encoder: Encoder<T>;\n\t#initial?: T;\n\n\t// The group a delta would be appended to, open only while deltas are enabled.\n\t#group?: Moq.Group.Producer;\n\t// Whether the encoder can emit deltas at all. With them off every frame is a snapshot, so a group\n\t// is closed the moment it's written and never held open.\n\t#deltas: boolean;\n\n\tconstructor(config: ProducerConfig<T>) {\n\t\tthis.#track = config.track;\n\t\tthis.#encoder = new Encoder(config);\n\t\tthis.#initial = config.initial;\n\t\tthis.#deltas = (config.deltaRatio ?? DEFAULT_DELTA_RATIO) !== 0;\n\t}\n\n\t/** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */\n\tupdate(value: T): void {\n\t\tconst frame = this.#encoder.update(value);\n\t\tif (!frame) return;\n\n\t\t// A throw here leaves the frame uncommitted, so the next update resynchronizes with a fresh\n\t\t// snapshot. A delta against a snapshot no consumer ever saw would be unreadable.\n\t\tthis.#write(frame);\n\t\tframe.commit();\n\t}\n\n\t#write(encoded: Encoded): void {\n\t\tif (encoded.keyframe) {\n\t\t\t// The previous group is complete; no more frames will be appended to it. Drop the handle\n\t\t\t// before opening the next one, so a failure below doesn't leave a closed group behind.\n\t\t\tthis.#group?.close();\n\t\t\tthis.#group = undefined;\n\n\t\t\tconst group = this.#track.appendGroup();\n\t\t\ttry {\n\t\t\t\tgroup.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() });\n\t\t\t} catch (err) {\n\t\t\t\t// The group carries no frames, so close it rather than leaving it open on the track. A\n\t\t\t\t// rejected frame doesn't close the track, and a consumer that already advanced into this\n\t\t\t\t// group would otherwise block until some later update opened a newer one.\n\t\t\t\tgroup.close();\n\t\t\t\tthrow err;\n\t\t\t}\n\n\t\t\tif (this.#deltas) {\n\t\t\t\t// Keep the group open so future deltas can be appended.\n\t\t\t\tthis.#group = group;\n\t\t\t} else {\n\t\t\t\t// Deltas disabled: one frame per group, identical to a plain JSON track.\n\t\t\t\tgroup.close();\n\t\t\t\tthis.#group = undefined;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.#group) throw new Error(\"delta with no open group\");\n\t\tthis.#group.writeFrame({ payload: encoded.payload, timestamp: Time.Timestamp.now() });\n\t}\n\n\t/**\n\t * Mutate the current value in place and publish the result.\n\t *\n\t * The callback receives a deep clone of the current value: everything published through this\n\t * producer so far, composed, falling back to {@link Config.initial} if nothing has been (throws if\n\t * neither exists). Edit it in place; on return the result is published via {@link update}, a no-op\n\t * if unchanged:\n\t *\n\t * ```ts\n\t * producer.mutate((catalog) => {\n\t * \tcatalog.scte35 = { ... };\n\t * });\n\t * ```\n\t *\n\t * Independent owners can share a single Producer and each edit only their own keys: every call\n\t * starts from the latest value, so sections compose instead of clobbering one another. Use\n\t * {@link update} to replace the whole value instead.\n\t *\n\t * After a rejected write the current value is what the producer last *tried* to publish, which\n\t * consumers never received. That is deliberate: dropping a rejected owner's field would clobber it\n\t * for whoever edits next, which is the failure this API exists to prevent. The owner whose write\n\t * failed sees the error, and the next successful publish is a full snapshot carrying the composed\n\t * value, so consumers converge on it either way.\n\t */\n\tmutate(fn: (value: T) => void): void {\n\t\t// Start from the last-published value, falling back to the configured initial value. We\n\t\t// don't invent an empty object: mutating with nothing to start from is a usage error.\n\t\tconst base = this.#encoder.value ?? this.#initial;\n\t\tif (base === undefined) {\n\t\t\tthrow new Error(\"mutate() requires a prior update() or `initial` in the config\");\n\t\t}\n\n\t\tconst value = structuredClone(base) as T;\n\t\tfn(value);\n\t\tthis.update(value);\n\t}\n\n\t/** Finish the track, closing any open group. */\n\tfinish(): void {\n\t\tthis.#group?.close();\n\t\tthis.#group = undefined;\n\n\t\t// The open group goes with the track, so the encoder must not keep emitting deltas into it.\n\t\t// Any further update fails on the closed track, but it has to fail as a track error rather\n\t\t// than as a delta with nowhere to put it.\n\t\tthis.#encoder.reset();\n\t\tthis.#track.close();\n\t}\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type * as Moq from "@norskvideo/moq-net";
|
|
2
|
+
import { type ConsumerConfig } from "./decoder";
|
|
3
|
+
/**
|
|
4
|
+
* Consumes an ordered log of JSON records from a track, yielding every record in order.
|
|
5
|
+
*
|
|
6
|
+
* A {@link Decoder} that owns its track. The log rides a single group, so this reads that group's
|
|
7
|
+
* frames in order; one record per frame. When something else already owns the track, use the
|
|
8
|
+
* {@link Decoder} directly.
|
|
9
|
+
*/
|
|
10
|
+
export declare class Consumer<T> {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(track: Moq.Track.Subscriber, config?: ConsumerConfig);
|
|
13
|
+
/** Get the next record, or `undefined` once the track ends. */
|
|
14
|
+
next(): Promise<T | undefined>;
|
|
15
|
+
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=consumer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consumer.d.ts","sourceRoot":"","sources":["../../src/stream/consumer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAErC,OAAO,EAAE,KAAK,cAAc,EAAW,MAAM,cAAc,CAAC;AAE5D;;;;;;GAMG;AACH,qBAAa,QAAQ,CAAC,CAAC;;IAMtB,YAAY,KAAK,EAAE,GAAG,CAAC,KAAK,CAAC,UAAU,EAAE,MAAM,GAAE,cAAmB,EAGnE;IAED,+DAA+D;IACzD,IAAI,IAAI,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAkBnC;IAEM,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,CAMhD;CACD"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* @ts-self-types="./consumer.d.ts" */
|
|
2
|
+
import { Decoder } from "./decoder.js";
|
|
3
|
+
/**
|
|
4
|
+
* Consumes an ordered log of JSON records from a track, yielding every record in order.
|
|
5
|
+
*
|
|
6
|
+
* A {@link Decoder} that owns its track. The log rides a single group, so this reads that group's
|
|
7
|
+
* frames in order; one record per frame. When something else already owns the track, use the
|
|
8
|
+
* {@link Decoder} directly.
|
|
9
|
+
*/
|
|
10
|
+
export class Consumer {
|
|
11
|
+
#track;
|
|
12
|
+
#decoder;
|
|
13
|
+
#group;
|
|
14
|
+
constructor(track, config = {}) {
|
|
15
|
+
this.#track = track;
|
|
16
|
+
this.#decoder = new Decoder(config);
|
|
17
|
+
}
|
|
18
|
+
/** Get the next record, or `undefined` once the track ends. */
|
|
19
|
+
async next() {
|
|
20
|
+
for (;;) {
|
|
21
|
+
if (!this.#group) {
|
|
22
|
+
this.#group = await this.#track.nextGroup();
|
|
23
|
+
if (!this.#group)
|
|
24
|
+
return undefined;
|
|
25
|
+
// Each group is its own compressed stream, so the window starts cold.
|
|
26
|
+
this.#decoder.reset();
|
|
27
|
+
}
|
|
28
|
+
const frame = await this.#group.readFrame();
|
|
29
|
+
if (frame === undefined) {
|
|
30
|
+
// The group is finished; the log rides just this one, so the stream ends.
|
|
31
|
+
this.#group = undefined;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
return this.#decoder.decode(frame.payload);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async *[Symbol.asyncIterator]() {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const value = await this.next();
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return;
|
|
42
|
+
yield value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=consumer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consumer.js","sourceRoot":"","sources":["../../src/stream/consumer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAuB,OAAO,EAAE,MAAM,cAAc,CAAC;AAE5D;;;;;;GAMG;AACH,MAAM,OAAO,QAAQ;IACpB,MAAM,CAAuB;IAC7B,QAAQ,CAAa;IAErB,MAAM,CAAsB;IAE5B,YAAY,KAA2B,EAAE,MAAM,GAAmB,EAAE;QACnE,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;QACpB,IAAI,CAAC,QAAQ,GAAG,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IACrC,CAAC;IAED,+DAA+D;IAC/D,KAAK,CAAC,IAAI;QACT,SAAS,CAAC;YACT,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;gBAClB,IAAI,CAAC,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC;gBAC5C,IAAI,CAAC,IAAI,CAAC,MAAM;oBAAE,OAAO,SAAS,CAAC;gBACnC,sEAAsE;gBACtE,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC;YACvB,CAAC;YAED,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC;YAC5C,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACzB,0EAA0E;gBAC1E,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC;gBACxB,SAAS;YACV,CAAC;YAED,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC5C,CAAC;IACF,CAAC;IAED,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC;QAC5B,SAAS,CAAC;YACT,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;YAChC,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO;YAChC,MAAM,KAAK,CAAC;QACb,CAAC;IACF,CAAC;CACD","sourcesContent":["import type * as Moq from \"@moq/net\";\n\nimport { type ConsumerConfig, Decoder } from \"./decoder.ts\";\n\n/**\n * Consumes an ordered log of JSON records from a track, yielding every record in order.\n *\n * A {@link Decoder} that owns its track. The log rides a single group, so this reads that group's\n * frames in order; one record per frame. When something else already owns the track, use the\n * {@link Decoder} directly.\n */\nexport class Consumer<T> {\n\t#track: Moq.Track.Subscriber;\n\t#decoder: Decoder<T>;\n\n\t#group?: Moq.Group.Consumer;\n\n\tconstructor(track: Moq.Track.Subscriber, config: ConsumerConfig = {}) {\n\t\tthis.#track = track;\n\t\tthis.#decoder = new Decoder(config);\n\t}\n\n\t/** Get the next record, or `undefined` once the track ends. */\n\tasync next(): Promise<T | undefined> {\n\t\tfor (;;) {\n\t\t\tif (!this.#group) {\n\t\t\t\tthis.#group = await this.#track.nextGroup();\n\t\t\t\tif (!this.#group) return undefined;\n\t\t\t\t// Each group is its own compressed stream, so the window starts cold.\n\t\t\t\tthis.#decoder.reset();\n\t\t\t}\n\n\t\t\tconst frame = await this.#group.readFrame();\n\t\t\tif (frame === undefined) {\n\t\t\t\t// The group is finished; the log rides just this one, so the stream ends.\n\t\t\t\tthis.#group = undefined;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\treturn this.#decoder.decode(frame.payload);\n\t\t}\n\t}\n\n\tasync *[Symbol.asyncIterator](): AsyncIterator<T> {\n\t\tfor (;;) {\n\t\t\tconst value = await this.next();\n\t\t\tif (value === undefined) return;\n\t\t\tyield value;\n\t\t}\n\t}\n}\n"]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Options shared by a {@link Decoder} and the {@link Consumer} that wraps one. */
|
|
2
|
+
export interface ConsumerConfig {
|
|
3
|
+
/** Whether the frames are `deflate-raw` compressed. Must match the encoder. Defaults to `false`. */
|
|
4
|
+
compression?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Decodes JSON records from frame payloads, sharing one DEFLATE window across the log.
|
|
8
|
+
*
|
|
9
|
+
* The track-free core of {@link Consumer}, and the mirror of {@link Encoder}. Payloads must be fed
|
|
10
|
+
* in the order they were encoded, since each one builds on the window the earlier ones left behind.
|
|
11
|
+
* Call {@link reset} at a group boundary, matching the encoder.
|
|
12
|
+
*/
|
|
13
|
+
export declare class Decoder<T> {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(config?: ConsumerConfig);
|
|
16
|
+
/** Start a cold DEFLATE window, for a caller that has just moved to a new group. */
|
|
17
|
+
reset(): void;
|
|
18
|
+
/** Decode the next frame payload back into a record. */
|
|
19
|
+
decode(payload: Uint8Array): T;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=decoder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decoder.d.ts","sourceRoot":"","sources":["../../src/stream/decoder.ts"],"names":[],"mappings":"AAEA,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC9B,oGAAoG;IACpG,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,qBAAa,OAAO,CAAC,CAAC;;IAKrB,YAAY,MAAM,GAAE,cAAmB,EAGtC;IAED,oFAAoF;IACpF,KAAK,IAAI,IAAI,CAEZ;IAED,wDAAwD;IACxD,MAAM,CAAC,OAAO,EAAE,UAAU,GAAG,CAAC,CAG7B;CACD"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* @ts-self-types="./decoder.d.ts" */
|
|
2
|
+
import { Decoder as Flate } from "@norskvideo/moq-flate";
|
|
3
|
+
/**
|
|
4
|
+
* Decodes JSON records from frame payloads, sharing one DEFLATE window across the log.
|
|
5
|
+
*
|
|
6
|
+
* The track-free core of {@link Consumer}, and the mirror of {@link Encoder}. Payloads must be fed
|
|
7
|
+
* in the order they were encoded, since each one builds on the window the earlier ones left behind.
|
|
8
|
+
* Call {@link reset} at a group boundary, matching the encoder.
|
|
9
|
+
*/
|
|
10
|
+
export class Decoder {
|
|
11
|
+
#decompress;
|
|
12
|
+
// The DEFLATE window for the whole log, present while decompressing.
|
|
13
|
+
#flate;
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
this.#decompress = config.compression ?? false;
|
|
16
|
+
this.#flate = this.#decompress ? new Flate() : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Start a cold DEFLATE window, for a caller that has just moved to a new group. */
|
|
19
|
+
reset() {
|
|
20
|
+
this.#flate = this.#decompress ? new Flate() : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Decode the next frame payload back into a record. */
|
|
23
|
+
decode(payload) {
|
|
24
|
+
const plain = this.#flate ? this.#flate.frame(payload) : payload;
|
|
25
|
+
return JSON.parse(new TextDecoder().decode(plain));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=decoder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decoder.js","sourceRoot":"","sources":["../../src/stream/decoder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,YAAY,CAAC;AAQ9C;;;;;;GAMG;AACH,MAAM,OAAO,OAAO;IACnB,WAAW,CAAU;IACrB,qEAAqE;IACrE,MAAM,CAAS;IAEf,YAAY,MAAM,GAAmB,EAAE;QACtC,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,KAAK,CAAC;QAC/C,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1D,CAAC;IAED,oFAAoF;IACpF,KAAK;QACJ,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1D,CAAC;IAED,wDAAwD;IACxD,MAAM,CAAC,OAAmB;QACzB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;QACjE,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACpD,CAAC;CACD","sourcesContent":["import { Decoder as Flate } from \"@moq/flate\";\n\n/** Options shared by a {@link Decoder} and the {@link Consumer} that wraps one. */\nexport interface ConsumerConfig {\n\t/** Whether the frames are `deflate-raw` compressed. Must match the encoder. Defaults to `false`. */\n\tcompression?: boolean;\n}\n\n/**\n * Decodes JSON records from frame payloads, sharing one DEFLATE window across the log.\n *\n * The track-free core of {@link Consumer}, and the mirror of {@link Encoder}. Payloads must be fed\n * in the order they were encoded, since each one builds on the window the earlier ones left behind.\n * Call {@link reset} at a group boundary, matching the encoder.\n */\nexport class Decoder<T> {\n\t#decompress: boolean;\n\t// The DEFLATE window for the whole log, present while decompressing.\n\t#flate?: Flate;\n\n\tconstructor(config: ConsumerConfig = {}) {\n\t\tthis.#decompress = config.compression ?? false;\n\t\tthis.#flate = this.#decompress ? new Flate() : undefined;\n\t}\n\n\t/** Start a cold DEFLATE window, for a caller that has just moved to a new group. */\n\treset(): void {\n\t\tthis.#flate = this.#decompress ? new Flate() : undefined;\n\t}\n\n\t/** Decode the next frame payload back into a record. */\n\tdecode(payload: Uint8Array): T {\n\t\tconst plain = this.#flate ? this.#flate.frame(payload) : payload;\n\t\treturn JSON.parse(new TextDecoder().decode(plain));\n\t}\n}\n"]}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Options shared by an {@link Encoder} and the {@link Producer} that wraps one. */
|
|
2
|
+
export interface ProducerConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Compress the group as one sync-flushed `deflate-raw` stream, so each record reuses the earlier
|
|
5
|
+
* ones as context and shrinks sharply. A {@link Decoder} reading the frames must set the same
|
|
6
|
+
* flag. Defaults to `false`.
|
|
7
|
+
*/
|
|
8
|
+
compression?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* An encoded record the caller has not yet acknowledged writing, returned by {@link Encoder.encode}.
|
|
12
|
+
*
|
|
13
|
+
* Write the {@link payload}, then {@link commit}.
|
|
14
|
+
*
|
|
15
|
+
* A record that is never committed never reached the wire. With compression on that is
|
|
16
|
+
* unrecoverable within the group: the window is ahead of what the consumer holds, and a log has no
|
|
17
|
+
* keyframe to resynchronize on the way `Snapshot` does. So the encoder throws on the next
|
|
18
|
+
* {@link Encoder.encode} until the caller rolls a new group and calls {@link Encoder.reset}. Without
|
|
19
|
+
* compression each record stands alone, so a dropped one leaves a gap in the log but nothing
|
|
20
|
+
* undecodable, and encoding continues.
|
|
21
|
+
*/
|
|
22
|
+
export interface Pending {
|
|
23
|
+
/** The frame payload to write. */
|
|
24
|
+
payload: Uint8Array;
|
|
25
|
+
/**
|
|
26
|
+
* Acknowledge that the record reached the wire, keeping the encoder's window.
|
|
27
|
+
*
|
|
28
|
+
* Only call this once the write has actually succeeded.
|
|
29
|
+
*/
|
|
30
|
+
commit(): void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encodes JSON records into frame payloads, sharing one DEFLATE window across the log.
|
|
34
|
+
*
|
|
35
|
+
* The track-free core of {@link Producer}. Unlike the `Snapshot` encoder there are no group
|
|
36
|
+
* boundaries to report: a log is an unbroken sequence of self-contained records, so every payload is
|
|
37
|
+
* simply the next frame.
|
|
38
|
+
*
|
|
39
|
+
* The window spans everything encoded so far, so payloads must reach the wire in order and be
|
|
40
|
+
* decoded in the same order. If the caller does roll a group, call {@link reset} so the next record
|
|
41
|
+
* starts a cold window that the new group's decoder can follow.
|
|
42
|
+
*/
|
|
43
|
+
export declare class Encoder<T> {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(config?: ProducerConfig);
|
|
46
|
+
/**
|
|
47
|
+
* Start a cold DEFLATE window, for a caller that has just rolled a group.
|
|
48
|
+
*
|
|
49
|
+
* This is also how a caller clears a desync: roll a new group so the consumer starts its own cold
|
|
50
|
+
* window, then reset.
|
|
51
|
+
*/
|
|
52
|
+
reset(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Encode one record into the next frame payload.
|
|
55
|
+
*
|
|
56
|
+
* The record comes back as a {@link Pending} the caller writes and then commits. Throws if a
|
|
57
|
+
* previous compressed record was left uncommitted, since every frame after it would be
|
|
58
|
+
* undecodable.
|
|
59
|
+
*/
|
|
60
|
+
encode(value: T): Pending;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=encoder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encoder.d.ts","sourceRoot":"","sources":["../../src/stream/encoder.ts"],"names":[],"mappings":"AAEA,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC9B;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,OAAO;IACvB,kCAAkC;IAClC,OAAO,EAAE,UAAU,CAAC;IAEpB;;;;OAIG;IACH,MAAM,IAAI,IAAI,CAAC;CACf;AAED;;;;;;;;;;GAUG;AACH,qBAAa,OAAO,CAAC,CAAC;;IAcrB,YAAY,MAAM,GAAE,cAAmB,EAGtC;IAED;;;;;OAKG;IACH,KAAK,IAAI,IAAI,CAIZ;IAED;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CA8BxB;CACD"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* @ts-self-types="./encoder.d.ts" */
|
|
2
|
+
import { Encoder as Flate } from "@norskvideo/moq-flate";
|
|
3
|
+
/**
|
|
4
|
+
* Encodes JSON records into frame payloads, sharing one DEFLATE window across the log.
|
|
5
|
+
*
|
|
6
|
+
* The track-free core of {@link Producer}. Unlike the `Snapshot` encoder there are no group
|
|
7
|
+
* boundaries to report: a log is an unbroken sequence of self-contained records, so every payload is
|
|
8
|
+
* simply the next frame.
|
|
9
|
+
*
|
|
10
|
+
* The window spans everything encoded so far, so payloads must reach the wire in order and be
|
|
11
|
+
* decoded in the same order. If the caller does roll a group, call {@link reset} so the next record
|
|
12
|
+
* starts a cold window that the new group's decoder can follow.
|
|
13
|
+
*/
|
|
14
|
+
export class Encoder {
|
|
15
|
+
#compress;
|
|
16
|
+
// The DEFLATE window for the whole log, present while compressing.
|
|
17
|
+
#flate;
|
|
18
|
+
// Set when a compressed record was encoded but never written. The window is then ahead of the
|
|
19
|
+
// consumer for the rest of the group, so encoding stops until the caller rolls a new one.
|
|
20
|
+
#desynced = false;
|
|
21
|
+
// Whether the record from the last {@link encode} is still unacknowledged.
|
|
22
|
+
#pending = false;
|
|
23
|
+
// Bumped for each record handed out, so a commit that arrives after the encoder has moved on can
|
|
24
|
+
// tell that it is acknowledging a record that is no longer the outstanding one.
|
|
25
|
+
#generation = 0;
|
|
26
|
+
constructor(config = {}) {
|
|
27
|
+
this.#compress = config.compression ?? false;
|
|
28
|
+
this.#flate = this.#compress ? new Flate() : undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Start a cold DEFLATE window, for a caller that has just rolled a group.
|
|
32
|
+
*
|
|
33
|
+
* This is also how a caller clears a desync: roll a new group so the consumer starts its own cold
|
|
34
|
+
* window, then reset.
|
|
35
|
+
*/
|
|
36
|
+
reset() {
|
|
37
|
+
this.#flate = this.#compress ? new Flate() : undefined;
|
|
38
|
+
this.#desynced = false;
|
|
39
|
+
this.#pending = false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Encode one record into the next frame payload.
|
|
43
|
+
*
|
|
44
|
+
* The record comes back as a {@link Pending} the caller writes and then commits. Throws if a
|
|
45
|
+
* previous compressed record was left uncommitted, since every frame after it would be
|
|
46
|
+
* undecodable.
|
|
47
|
+
*/
|
|
48
|
+
encode(value) {
|
|
49
|
+
// An uncompressed record carries no shared state, so losing one leaves a gap in the log rather
|
|
50
|
+
// than an undecodable stream, and encoding continues.
|
|
51
|
+
if (this.#pending && this.#compress)
|
|
52
|
+
this.#desynced = true;
|
|
53
|
+
if (this.#desynced) {
|
|
54
|
+
throw new Error("compression desynchronized: a record was encoded but never written");
|
|
55
|
+
}
|
|
56
|
+
const text = JSON.stringify(value);
|
|
57
|
+
if (text === undefined) {
|
|
58
|
+
// `JSON.stringify` yields undefined for a top-level undefined, function, or symbol, which
|
|
59
|
+
// would otherwise frame as empty bytes and fail on the consumer instead of here.
|
|
60
|
+
throw new Error("record is not representable as JSON");
|
|
61
|
+
}
|
|
62
|
+
const bytes = new TextEncoder().encode(text);
|
|
63
|
+
const payload = this.#flate ? this.#flate.frame(bytes) : bytes;
|
|
64
|
+
this.#pending = true;
|
|
65
|
+
const generation = ++this.#generation;
|
|
66
|
+
return {
|
|
67
|
+
payload,
|
|
68
|
+
commit: () => {
|
|
69
|
+
// A caller that starts the next encode before this record settles has already made the
|
|
70
|
+
// encoder account for it. Acknowledging it now would clear the flag belonging to the newer
|
|
71
|
+
// record, so a later loss of that one would go unnoticed.
|
|
72
|
+
if (this.#generation === generation)
|
|
73
|
+
this.#pending = false;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=encoder.js.map
|