@norskvideo/moq-json 0.1.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 (50) hide show
  1. package/README.md +60 -0
  2. package/diff.d.ts +20 -0
  3. package/diff.d.ts.map +1 -0
  4. package/diff.js +83 -0
  5. package/diff.js.map +1 -0
  6. package/diff.test.d.ts +2 -0
  7. package/diff.test.d.ts.map +1 -0
  8. package/diff.test.js +60 -0
  9. package/diff.test.js.map +1 -0
  10. package/index.d.ts +17 -0
  11. package/index.d.ts.map +1 -0
  12. package/index.js +20 -0
  13. package/index.js.map +1 -0
  14. package/package.json +23 -0
  15. package/snapshot/compression.test.d.ts +2 -0
  16. package/snapshot/compression.test.d.ts.map +1 -0
  17. package/snapshot/compression.test.js +132 -0
  18. package/snapshot/compression.test.js.map +1 -0
  19. package/snapshot/consumer.d.ts +25 -0
  20. package/snapshot/consumer.d.ts.map +1 -0
  21. package/snapshot/consumer.js +93 -0
  22. package/snapshot/consumer.js.map +1 -0
  23. package/snapshot/index.d.ts +19 -0
  24. package/snapshot/index.d.ts.map +1 -0
  25. package/snapshot/index.js +20 -0
  26. package/snapshot/index.js.map +1 -0
  27. package/snapshot/producer.d.ts +64 -0
  28. package/snapshot/producer.d.ts.map +1 -0
  29. package/snapshot/producer.js +230 -0
  30. package/snapshot/producer.js.map +1 -0
  31. package/snapshot/producer.test.d.ts +2 -0
  32. package/snapshot/producer.test.d.ts.map +1 -0
  33. package/snapshot/producer.test.js +75 -0
  34. package/snapshot/producer.test.js.map +1 -0
  35. package/snapshot/snapshot.test.d.ts +2 -0
  36. package/snapshot/snapshot.test.d.ts.map +1 -0
  37. package/snapshot/snapshot.test.js +185 -0
  38. package/snapshot/snapshot.test.js.map +1 -0
  39. package/stream.d.ts +54 -0
  40. package/stream.d.ts.map +1 -0
  41. package/stream.js +92 -0
  42. package/stream.js.map +1 -0
  43. package/stream.test.d.ts +2 -0
  44. package/stream.test.d.ts.map +1 -0
  45. package/stream.test.js +64 -0
  46. package/stream.test.js.map +1 -0
  47. package/vectors.test.d.ts +2 -0
  48. package/vectors.test.d.ts.map +1 -0
  49. package/vectors.test.js +18 -0
  50. package/vectors.test.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consumer.js","sourceRoot":"","sources":["../../src/snapshot/consumer.ts"],"names":[],"mappings":"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","sourcesContent":["import { Decoder } from \"@moq/flate\";\nimport type * as Moq from \"@moq/net\";\nimport type * as z from \"zod/mini\";\nimport { merge } from \"../diff.ts\";\nimport type { Config } from \"./producer.ts\";\n\n/**\n * Consumes a JSON value from a track, reconstructing it from snapshots and deltas.\n *\n * Reads each group's snapshot (frame 0) and applies the following frames as merge patches. A live\n * consumer yields each update as it arrives; a consumer that has fallen behind (or just joined)\n * collapses the buffered backlog and yields only the latest value. See {@link next}.\n */\nexport class Consumer<T> {\n\t#track: Moq.Track;\n\t#schema?: z.ZodMiniType<T>;\n\t// Whether frames are `deflate-raw` compressed. Must match the producer's {@link Config.compression}.\n\t#decompress: boolean;\n\n\t#group?: Moq.Group;\n\t// Per-group DEFLATE decoder, built lazily on the first frame of a group and reset at each boundary.\n\t#decoder?: Decoder;\n\t#current?: unknown;\n\t#framesRead = 0;\n\n\tconstructor(track: Moq.Track, config: Config<T> = {}) {\n\t\tthis.#track = track;\n\t\tthis.#schema = config.schema;\n\t\tthis.#decompress = config.compression ?? false;\n\t}\n\n\t/**\n\t * Get the next reconstructed value, or `undefined` once the track ends.\n\t *\n\t * Applies every frame already buffered in the group but yields only the latest reconstructed\n\t * value: the intermediate reconstructions are stale, so a late joiner (or any consumer that has\n\t * fallen behind) catches up to the head in one step instead of replaying every superseded state.\n\t * Frames are still decoded in order (the DEFLATE window and merge patches are sequential); only\n\t * the per-frame yield is skipped.\n\t */\n\tasync next(): Promise<T | undefined> {\n\t\tfor (;;) {\n\t\t\tif (!this.#group) {\n\t\t\t\t// Advance to the next group with a higher sequence number (skipping late arrivals).\n\t\t\t\tthis.#group = await this.#track.nextGroupOrdered();\n\t\t\t\tif (!this.#group) return undefined;\n\t\t\t\tthis.#current = undefined;\n\t\t\t\tthis.#framesRead = 0;\n\t\t\t\t// Each group is its own compressed stream, so start a fresh decoder.\n\t\t\t\tthis.#decoder = undefined;\n\t\t\t}\n\n\t\t\t// Drain every frame already buffered, keeping only the latest reconstructed value.\n\t\t\tlet value: T | undefined;\n\t\t\tlet advanced = false;\n\t\t\tfor (let frame = this.#group.tryReadFrame(); frame !== undefined; frame = this.#group.tryReadFrame()) {\n\t\t\t\tvalue = this.#apply(frame);\n\t\t\t\tadvanced = true;\n\t\t\t}\n\t\t\tif (advanced) return value;\n\n\t\t\t// Nothing buffered: block for the next frame (or the group's end).\n\t\t\tconst frame = await this.#group.readFrame();\n\t\t\tif (frame === undefined) {\n\t\t\t\t// The group is exhausted; advance to the next one.\n\t\t\t\tthis.#group = undefined;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\treturn this.#apply(frame);\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\t// Frame 0 of a group is a snapshot, the rest are merge patches. When compressed, frames share one\n\t// per-group DEFLATE stream, so they decode in order through a decoder built on the group's first frame.\n\t#apply(frame: Uint8Array): T {\n\t\tlet payload = frame;\n\t\tif (this.#decompress) {\n\t\t\tthis.#decoder ??= new Decoder();\n\t\t\tpayload = this.#decoder.frame(frame);\n\t\t}\n\t\tconst parsed = JSON.parse(new TextDecoder().decode(payload));\n\t\tif (this.#framesRead === 0) {\n\t\t\tthis.#current = parsed;\n\t\t} else {\n\t\t\tthis.#current = merge(this.#current, parsed);\n\t\t}\n\t\tthis.#framesRead += 1;\n\n\t\treturn this.#schema ? this.#schema.parse(this.#current) : (this.#current as T);\n\t}\n}\n"]}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Lossy latest-value JSON publishing over MoQ tracks.
3
+ *
4
+ * One JSON value updated over time, for consumers that only care about the current state (a
5
+ * catalog, a status document). This mode is **lossy** by design: a consumer yields only the most
6
+ * recent value. A late joiner (or a consumer that falls behind) jumps straight to the newest
7
+ * group and collapses any buffered backlog into a single yield, and older groups are dropped
8
+ * entirely. Intermediate updates are never replayed. For an ordered log where every record is
9
+ * preserved, use the `Stream` module instead.
10
+ *
11
+ * On the wire the value is a series of self-contained groups: frame 0 is a full snapshot and any
12
+ * following frames are RFC 7396 JSON Merge Patch deltas applied in order. Interoperable with the
13
+ * Rust `moq_json::snapshot`.
14
+ *
15
+ * @module
16
+ */
17
+ export { Consumer } from "./consumer";
18
+ export { type Config, Producer } from "./producer";
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/snapshot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,KAAK,MAAM,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,20 @@
1
+ /* @ts-self-types="./index.d.ts" */
2
+ /**
3
+ * Lossy latest-value JSON publishing over MoQ tracks.
4
+ *
5
+ * One JSON value updated over time, for consumers that only care about the current state (a
6
+ * catalog, a status document). This mode is **lossy** by design: a consumer yields only the most
7
+ * recent value. A late joiner (or a consumer that falls behind) jumps straight to the newest
8
+ * group and collapses any buffered backlog into a single yield, and older groups are dropped
9
+ * entirely. Intermediate updates are never replayed. For an ordered log where every record is
10
+ * preserved, use the `Stream` module instead.
11
+ *
12
+ * On the wire the value is a series of self-contained groups: frame 0 is a full snapshot and any
13
+ * following frames are RFC 7396 JSON Merge Patch deltas applied in order. Interoperable with the
14
+ * Rust `moq_json::snapshot`.
15
+ *
16
+ * @module
17
+ */
18
+ export { Consumer } from "./consumer.js";
19
+ export { Producer } from "./producer.js";
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/snapshot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAe,QAAQ,EAAE,MAAM,eAAe,CAAC","sourcesContent":["/**\n * Lossy latest-value JSON publishing over MoQ tracks.\n *\n * One JSON value updated over time, for consumers that only care about the current state (a\n * catalog, a status document). This mode is **lossy** by design: a consumer yields only the most\n * recent value. A late joiner (or a consumer that falls behind) jumps straight to the newest\n * group and collapses any buffered backlog into a single yield, and older groups are dropped\n * entirely. Intermediate updates are never replayed. For an ordered log where every record is\n * preserved, use the `Stream` module instead.\n *\n * On the wire the value is a series of self-contained groups: frame 0 is a full snapshot and any\n * following frames are RFC 7396 JSON Merge Patch deltas applied in order. Interoperable with the\n * Rust `moq_json::snapshot`.\n *\n * @module\n */\n\nexport { Consumer } from \"./consumer.ts\";\nexport { type Config, Producer } from \"./producer.ts\";\n"]}
@@ -0,0 +1,64 @@
1
+ import * as Moq from "@norskvideo/moq-net";
2
+ import type { Effect } from "@norskvideo/moq-signals";
3
+ import type * as z from "zod/mini";
4
+ export interface Config<T> {
5
+ deltaRatio?: number;
6
+ schema?: z.ZodMiniType<T>;
7
+ initial?: T;
8
+ compression?: boolean;
9
+ }
10
+ /**
11
+ * Publishes a JSON value as snapshots and deltas, chosen automatically.
12
+ *
13
+ * Construct it two ways:
14
+ *
15
+ * - **With a track** (`new Producer(track, config)`): writes directly to that one track.
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.
19
+ */
20
+ export declare class Producer<T> {
21
+ #private;
22
+ /** Create a track-less, fan-out producer; attach subscribers with {@link serve}. */
23
+ constructor(config?: Config<T>);
24
+ /** Create a producer that writes directly to `track`. */
25
+ constructor(track: Moq.Track, config?: Config<T>);
26
+ /** The current value, or `undefined` if nothing has been published yet. */
27
+ get value(): T | undefined;
28
+ /** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */
29
+ update(value: T): void;
30
+ /**
31
+ * Mutate the current value in place and publish the result.
32
+ *
33
+ * The callback receives a deep clone of the last-published value, falling back to
34
+ * {@link Config.initial} if nothing has been published yet (throws if neither exists). Edit it in
35
+ * place; on return the result is published via {@link update}, a no-op if unchanged:
36
+ *
37
+ * ```ts
38
+ * producer.mutate((catalog) => {
39
+ * catalog.scte35 = { ... };
40
+ * });
41
+ * ```
42
+ *
43
+ * Independent owners can share a single Producer and each edit only their own keys: every call
44
+ * starts from the latest value, so sections compose instead of clobbering one another. Use
45
+ * {@link update} to replace the whole value instead.
46
+ */
47
+ mutate(fn: (value: T) => void): void;
48
+ /**
49
+ * Serve a subscription request: seed the track with the current value, then forward updates.
50
+ *
51
+ * Only available on a track-less (fan-out) producer. The subscriber is removed and finished when
52
+ * `effect` is cleaned up.
53
+ *
54
+ * Pass `opts.compression` to override the producer's configured compression for this subscriber
55
+ * only, so one fan-out producer can serve the same value both plaintext and `deflate-raw` (e.g.
56
+ * the catalog served on `catalog.json` and `catalog.json.z`).
57
+ */
58
+ serve(track: Moq.Track, effect: Effect, opts?: {
59
+ compression?: boolean;
60
+ }): void;
61
+ /** Finish: close the track (leaf) or finish every subscriber (fan-out). */
62
+ finish(): void;
63
+ }
64
+ //# sourceMappingURL=producer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"producer.d.ts","sourceRoot":"","sources":["../../src/snapshot/producer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAChC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,KAAK,CAAC,MAAM,UAAU,CAAC;AAWnC,MAAM,WAAW,MAAM,CAAC,CAAC;IAcxB,UAAU,CAAC,EAAE,MAAM,CAAC;IAGpB,MAAM,CAAC,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;IAI1B,OAAO,CAAC,EAAE,CAAC,CAAC;IAMZ,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,qBAAa,QAAQ,CAAC,CAAC;;IAyBtB,oFAAoF;IACpF,YAAY,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE;IAChC,yDAAyD;IACzD,YAAY,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE;IAalD,2EAA2E;IAC3E,IAAI,KAAK,IAAI,CAAC,GAAG,SAAS,CAEzB;IAED,2FAA2F;IAC3F,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAuCrB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,GAAG,IAAI,CAWnC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAe9E;IAED,2EAA2E;IAC3E,MAAM,IAAI,IAAI,CAUb;CAuED"}
@@ -0,0 +1,230 @@
1
+ /* @ts-self-types="./producer.d.ts" */
2
+ import { Encoder } from "@norskvideo/moq-flate";
3
+ import * as Moq from "@norskvideo/moq-net";
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;
10
+ /**
11
+ * Publishes a JSON value as snapshots and deltas, chosen automatically.
12
+ *
13
+ * Construct it two ways:
14
+ *
15
+ * - **With a track** (`new Producer(track, config)`): writes directly to that one track.
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.
19
+ */
20
+ export class Producer {
21
+ #config;
22
+ // Leaf mode: writes snapshots/deltas straight to a single track.
23
+ #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
+ #encoder;
38
+ // Fan-out mode: retains the value and serves a child (leaf) Producer per subscriber.
39
+ #outputs;
40
+ #value;
41
+ constructor(trackOrConfig, config = {}) {
42
+ if (trackOrConfig instanceof Moq.Track) {
43
+ this.#track = trackOrConfig;
44
+ this.#config = config;
45
+ }
46
+ else {
47
+ this.#config = trackOrConfig ?? {};
48
+ this.#outputs = new Set();
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;
56
+ }
57
+ /** Publish a new value, emitting a snapshot or delta automatically. No-op if unchanged. */
58
+ update(value) {
59
+ if (!this.#track) {
60
+ // Fan-out: retain the value and forward it to every subscriber. Isolate per-subscriber
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
+ }
78
+ return;
79
+ }
80
+ const valid = this.#config.schema ? this.#config.schema.parse(value) : value;
81
+ // Serialize once; parse it back to a normalized JSON value for diffing and comparison
82
+ // (dropping `undefined` fields, matching what lands on the wire).
83
+ const text = JSON.stringify(valid);
84
+ const json = JSON.parse(text);
85
+ if (this.#last !== undefined && deepEqual(this.#last, json))
86
+ 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
+ }
93
+ else {
94
+ this.#snapshot(this.#track, snapshot);
95
+ }
96
+ this.#last = json;
97
+ }
98
+ /**
99
+ * Mutate the current value in place and publish the result.
100
+ *
101
+ * The callback receives a deep clone of the last-published value, falling back to
102
+ * {@link Config.initial} if nothing has been published yet (throws if neither exists). Edit it in
103
+ * place; on return the result is published via {@link update}, a no-op if unchanged:
104
+ *
105
+ * ```ts
106
+ * producer.mutate((catalog) => {
107
+ * catalog.scte35 = { ... };
108
+ * });
109
+ * ```
110
+ *
111
+ * Independent owners can share a single Producer and each edit only their own keys: every call
112
+ * starts from the latest value, so sections compose instead of clobbering one another. Use
113
+ * {@link update} to replace the whole value instead.
114
+ */
115
+ mutate(fn) {
116
+ // Start from the last-published value, falling back to the configured initial value. We
117
+ // don't invent an empty object: mutating with nothing to start from is a usage error.
118
+ const base = (this.#track ? this.#last : this.#value) ?? this.#config.initial;
119
+ if (base === undefined) {
120
+ throw new Error("mutate() requires a prior update() or `initial` in the config");
121
+ }
122
+ const value = structuredClone(base);
123
+ fn(value);
124
+ this.update(value);
125
+ }
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). */
151
+ finish() {
152
+ if (!this.#track) {
153
+ for (const output of this.#outputs ?? [])
154
+ output.finish();
155
+ this.#outputs?.clear();
156
+ return;
157
+ }
158
+ this.#group?.close();
159
+ this.#group = undefined;
160
+ this.#track.close();
161
+ }
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
+ }
230
+ //# sourceMappingURL=producer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"producer.js","sourceRoot":"","sources":["../../src/snapshot/producer.ts"],"names":[],"mappings":"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","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"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=producer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"producer.test.d.ts","sourceRoot":"","sources":["../../src/snapshot/producer.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,75 @@
1
+ /* @ts-self-types="./producer.test.d.ts" */
2
+ import { expect, test } from "bun:test";
3
+ import { Track } from "@norskvideo/moq-net";
4
+ import { Effect } from "@norskvideo/moq-signals";
5
+ import { Consumer } from "./consumer.js";
6
+ import { Producer } from "./producer.js";
7
+ test("a track-less producer seeds subscribers and fans out edits", async () => {
8
+ const source = new Producer({ initial: {} });
9
+ // Edit before anyone subscribes: the value is retained, not lost.
10
+ source.mutate((v) => {
11
+ v.video = { renditions: {} };
12
+ });
13
+ const effect = new Effect();
14
+ const track = new Track("catalog.json");
15
+ source.serve(track, effect);
16
+ const consumer = new Consumer(track);
17
+ // A new subscriber is seeded with the current value.
18
+ expect((await consumer.next())?.video).toEqual({ renditions: {} });
19
+ // An independent owner edits its own key; the subscriber sees it, the other key untouched.
20
+ source.mutate((v) => {
21
+ v.scte35 = { splices: [] };
22
+ });
23
+ const update = await consumer.next();
24
+ expect(update?.video).toEqual({ renditions: {} });
25
+ expect(update?.scte35).toEqual({ splices: [] });
26
+ effect.close();
27
+ });
28
+ test("a reconnecting subscriber is seeded with the full current value", async () => {
29
+ const source = new Producer({ initial: {} });
30
+ source.mutate((v) => {
31
+ v.video = { renditions: {} };
32
+ v.scte35 = { splices: [] };
33
+ });
34
+ // The first subscription drains and ends...
35
+ const first = new Effect();
36
+ source.serve(new Track("catalog.json"), first);
37
+ first.close();
38
+ // ...and a fresh subscription still gets the current value, not nothing.
39
+ const effect = new Effect();
40
+ const track = new Track("catalog.json");
41
+ source.serve(track, effect);
42
+ const seeded = await new Consumer(track).next();
43
+ expect(seeded?.video).toEqual({ renditions: {} });
44
+ expect(seeded?.scte35).toEqual({ splices: [] });
45
+ effect.close();
46
+ });
47
+ test("mutate without a value or initial throws", () => {
48
+ const source = new Producer();
49
+ expect(() => source.mutate(() => { })).toThrow();
50
+ });
51
+ test("serve() can override compression per subscriber", async () => {
52
+ // One fan-out producer serves the same value both plaintext and compressed.
53
+ const source = new Producer({ initial: {} });
54
+ source.mutate((v) => {
55
+ v.video = { renditions: { hd: { codec: "avc1.640028" } } };
56
+ });
57
+ const effect = new Effect();
58
+ const plainTrack = new Track("catalog.json");
59
+ source.serve(plainTrack, effect);
60
+ const plain = await new Consumer(plainTrack).next();
61
+ const zTrack = new Track("catalog.json.z");
62
+ source.serve(zTrack, effect, { compression: true });
63
+ const compressed = await new Consumer(zTrack, { compression: true }).next();
64
+ // Both tracks reconstruct the identical value despite different wire encodings.
65
+ expect(compressed).toEqual(plain);
66
+ expect(compressed?.video).toEqual({ renditions: { hd: { codec: "avc1.640028" } } });
67
+ effect.close();
68
+ });
69
+ test("serve() throws on a track-bound producer", () => {
70
+ const producer = new Producer(new Track("meta.json"));
71
+ const effect = new Effect();
72
+ expect(() => producer.serve(new Track("other"), effect)).toThrow();
73
+ effect.close();
74
+ });
75
+ //# sourceMappingURL=producer.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"producer.test.js","sourceRoot":"","sources":["../../src/snapshot/producer.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { expect, test } from \"bun:test\";\nimport { Track } from \"@moq/net\";\nimport { Effect } from \"@moq/signals\";\nimport { Consumer } from \"./consumer.ts\";\nimport { Producer } from \"./producer.ts\";\n\ntest(\"a track-less producer seeds subscribers and fans out edits\", async () => {\n\tconst source = new Producer<Record<string, unknown>>({ initial: {} });\n\n\t// Edit before anyone subscribes: the value is retained, not lost.\n\tsource.mutate((v) => {\n\t\tv.video = { renditions: {} };\n\t});\n\n\tconst effect = new Effect();\n\tconst track = new Track(\"catalog.json\");\n\tsource.serve(track, effect);\n\tconst consumer = new Consumer<Record<string, unknown>>(track);\n\n\t// A new subscriber is seeded with the current value.\n\texpect((await consumer.next())?.video).toEqual({ renditions: {} });\n\n\t// An independent owner edits its own key; the subscriber sees it, the other key untouched.\n\tsource.mutate((v) => {\n\t\tv.scte35 = { splices: [] };\n\t});\n\tconst update = await consumer.next();\n\texpect(update?.video).toEqual({ renditions: {} });\n\texpect(update?.scte35).toEqual({ splices: [] });\n\n\teffect.close();\n});\n\ntest(\"a reconnecting subscriber is seeded with the full current value\", async () => {\n\tconst source = new Producer<Record<string, unknown>>({ initial: {} });\n\tsource.mutate((v) => {\n\t\tv.video = { renditions: {} };\n\t\tv.scte35 = { splices: [] };\n\t});\n\n\t// The first subscription drains and ends...\n\tconst first = new Effect();\n\tsource.serve(new Track(\"catalog.json\"), first);\n\tfirst.close();\n\n\t// ...and a fresh subscription still gets the current value, not nothing.\n\tconst effect = new Effect();\n\tconst track = new Track(\"catalog.json\");\n\tsource.serve(track, effect);\n\tconst seeded = await new Consumer<Record<string, unknown>>(track).next();\n\texpect(seeded?.video).toEqual({ renditions: {} });\n\texpect(seeded?.scte35).toEqual({ splices: [] });\n\n\teffect.close();\n});\n\ntest(\"mutate without a value or initial throws\", () => {\n\tconst source = new Producer<Record<string, unknown>>();\n\texpect(() => source.mutate(() => {})).toThrow();\n});\n\ntest(\"serve() can override compression per subscriber\", async () => {\n\t// One fan-out producer serves the same value both plaintext and compressed.\n\tconst source = new Producer<Record<string, unknown>>({ initial: {} });\n\tsource.mutate((v) => {\n\t\tv.video = { renditions: { hd: { codec: \"avc1.640028\" } } };\n\t});\n\n\tconst effect = new Effect();\n\n\tconst plainTrack = new Track(\"catalog.json\");\n\tsource.serve(plainTrack, effect);\n\tconst plain = await new Consumer<Record<string, unknown>>(plainTrack).next();\n\n\tconst zTrack = new Track(\"catalog.json.z\");\n\tsource.serve(zTrack, effect, { compression: true });\n\tconst compressed = await new Consumer<Record<string, unknown>>(zTrack, { compression: true }).next();\n\n\t// Both tracks reconstruct the identical value despite different wire encodings.\n\texpect(compressed).toEqual(plain);\n\texpect(compressed?.video).toEqual({ renditions: { hd: { codec: \"avc1.640028\" } } });\n\n\teffect.close();\n});\n\ntest(\"serve() throws on a track-bound producer\", () => {\n\tconst producer = new Producer<Record<string, unknown>>(new Track(\"meta.json\"));\n\tconst effect = new Effect();\n\texpect(() => producer.serve(new Track(\"other\"), effect)).toThrow();\n\teffect.close();\n});\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=snapshot.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot.test.d.ts","sourceRoot":"","sources":["../../src/snapshot/snapshot.test.ts"],"names":[],"mappings":""}