@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
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ <p align="center">
2
+ <img height="128px" src="https://github.com/moq-dev/moq/blob/main/.github/logo.svg" alt="Media over QUIC">
3
+ </p>
4
+
5
+ # @moq/json
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@moq/json)](https://www.npmjs.com/package/@moq/json)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](https://www.typescriptlang.org/)
9
+
10
+ JSON publishing over [Media over QUIC](https://moq.dev/) tracks, in two modes:
11
+
12
+ - **`Snapshot`**: lossy. One JSON value updated over time; a consumer only gets the most recent value. Intermediate updates are collapsed and older groups are dropped.
13
+ - **`Stream`**: lossless. An ordered append-log of self-contained records; every record is preserved and delivered in order, nothing is ever superseded.
14
+
15
+ Pick `Snapshot` when consumers care about "what is the value now" (a catalog, a status document) and `Stream` when they care about every record (an event log, a media timeline).
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ bun add @moq/json
21
+ ```
22
+
23
+ ### Snapshot: the latest value
24
+
25
+ A JSON value is published over a [`@moq/net`](../net) track as a series of groups. Each group is self-contained: its first frame is a full snapshot and any following frames are [RFC 7396 JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7396.html) deltas applied in order. A consumer jumps to the newest group, reads the snapshot, and applies the deltas, so a late joiner never needs older groups. This is lossy by design: only the most recent value is delivered.
26
+
27
+ ```ts
28
+ import { Snapshot } from "@moq/json";
29
+
30
+ // Publish: each update supersedes the last.
31
+ const producer = new Snapshot.Producer(track);
32
+ producer.update({ hello: "world" });
33
+
34
+ // Consume: yields the latest reconstructed value, collapsing any backlog.
35
+ const consumer = new Snapshot.Consumer(track);
36
+ for await (const value of consumer) {
37
+ console.log(value);
38
+ }
39
+ ```
40
+
41
+ Pass `{ deltaRatio: x }` with a positive `x` to `Snapshot.Producer` to emit merge-patch deltas. A new snapshot group rolls once the deltas *already written* to the group exceed `x` times the fresh snapshot size; the delta that crosses the budget still lands first, so a group overshoots by at most one delta. `deltaRatio` defaults to `8` when unset. Set it to `0` to disable deltas: every change becomes a fresh single-frame snapshot group. Arrays are replaced wholesale within a delta; a value set to `null` falls back to a snapshot, since merge patch reads `null` as a key deletion.
42
+
43
+ ### Stream: every record
44
+
45
+ An ordered log of self-contained records, one JSON value per frame, all riding a single group. Nothing is superseded: a consumer yields every record in order.
46
+
47
+ ```ts
48
+ import { Stream } from "@moq/json";
49
+
50
+ const producer = new Stream.Producer(track);
51
+ producer.append({ event: "started" });
52
+ producer.append({ event: "stopped" });
53
+
54
+ const consumer = new Stream.Consumer(track);
55
+ for await (const record of consumer) {
56
+ console.log(record);
57
+ }
58
+ ```
59
+
60
+ Both modes support optional group-scoped DEFLATE compression (`{ compression: true }` on both sides), interoperable with the Rust `moq-json` crate.
package/diff.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface Diff {
2
+ patch: unknown;
3
+ forcedSnapshot: boolean;
4
+ }
5
+ /**
6
+ * Generate an RFC 7396 merge patch transforming `oldVal` into `newVal`.
7
+ *
8
+ * Only object roots produce a recursive patch; any other root forces a snapshot.
9
+ */
10
+ export declare function diff(oldVal: unknown, newVal: unknown): Diff;
11
+ /**
12
+ * Apply an RFC 7396 merge patch to a target value, returning the result.
13
+ *
14
+ * Objects merge recursively; a null patch value deletes that key; any other patch value
15
+ * (scalar or array) replaces the target wholesale.
16
+ */
17
+ export declare function merge(target: unknown, patch: unknown): unknown;
18
+ /** Structural equality for JSON values. */
19
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
20
+ //# sourceMappingURL=diff.d.ts.map
package/diff.d.ts.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../src/diff.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,IAAI;IAEpB,KAAK,EAAE,OAAO,CAAC;IAMf,cAAc,EAAE,OAAO,CAAC;CACxB;AAQD;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAQ3D;AAgCD;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAY9D;AAED,2CAA2C;AAC3C,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAczD"}
package/diff.js ADDED
@@ -0,0 +1,83 @@
1
+ /* @ts-self-types="./diff.d.ts" */
2
+ // RFC 7396 JSON Merge Patch: generate a patch between two values, and apply one.
3
+ function isObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ /**
7
+ * Generate an RFC 7396 merge patch transforming `oldVal` into `newVal`.
8
+ *
9
+ * Only object roots produce a recursive patch; any other root forces a snapshot.
10
+ */
11
+ export function diff(oldVal, newVal) {
12
+ if (isObject(oldVal) && isObject(newVal)) {
13
+ const patch = {};
14
+ const forced = { value: false };
15
+ diffObjects(oldVal, newVal, patch, forced);
16
+ return { patch, forcedSnapshot: forced.value };
17
+ }
18
+ return { patch: newVal, forcedSnapshot: true };
19
+ }
20
+ function diffObjects(oldObj, newObj, patch, forced) {
21
+ // Keys present in old but missing from new become explicit null deletions.
22
+ for (const key of Object.keys(oldObj)) {
23
+ if (!(key in newObj))
24
+ patch[key] = null;
25
+ }
26
+ for (const key of Object.keys(newObj)) {
27
+ const newV = newObj[key];
28
+ const oldV = oldObj[key];
29
+ const inOld = key in oldObj;
30
+ if (inOld && deepEqual(oldV, newV))
31
+ continue;
32
+ // Recurse into nested objects so unchanged sibling keys stay out of the patch.
33
+ if (isObject(oldV) && isObject(newV)) {
34
+ const sub = {};
35
+ diffObjects(oldV, newV, sub, forced);
36
+ if (Object.keys(sub).length > 0)
37
+ patch[key] = sub;
38
+ continue;
39
+ }
40
+ // Added or replaced with a non-object value. A literal null can't be stored: merge patch
41
+ // would delete the key. Arrays are kept in the patch and replace the target wholesale.
42
+ if (newV === null) {
43
+ forced.value = true;
44
+ }
45
+ patch[key] = newV;
46
+ }
47
+ }
48
+ /**
49
+ * Apply an RFC 7396 merge patch to a target value, returning the result.
50
+ *
51
+ * Objects merge recursively; a null patch value deletes that key; any other patch value
52
+ * (scalar or array) replaces the target wholesale.
53
+ */
54
+ export function merge(target, patch) {
55
+ if (!isObject(patch))
56
+ return patch;
57
+ const base = isObject(target) ? { ...target } : {};
58
+ for (const [key, value] of Object.entries(patch)) {
59
+ if (value === null) {
60
+ delete base[key];
61
+ }
62
+ else {
63
+ base[key] = merge(base[key], value);
64
+ }
65
+ }
66
+ return base;
67
+ }
68
+ /** Structural equality for JSON values. */
69
+ export function deepEqual(a, b) {
70
+ if (a === b)
71
+ return true;
72
+ if (Array.isArray(a) && Array.isArray(b)) {
73
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
74
+ }
75
+ if (isObject(a) && isObject(b)) {
76
+ const keys = Object.keys(a);
77
+ if (keys.length !== Object.keys(b).length)
78
+ return false;
79
+ return keys.every((key) => key in b && deepEqual(a[key], b[key]));
80
+ }
81
+ return false;
82
+ }
83
+ //# sourceMappingURL=diff.js.map
package/diff.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.js","sourceRoot":"","sources":["../src/diff.ts"],"names":[],"mappings":"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","sourcesContent":["// RFC 7396 JSON Merge Patch: generate a patch between two values, and apply one.\n\nexport interface Diff {\n\t// A merge patch transforming the old value into the new one.\n\tpatch: unknown;\n\n\t// Set when the change can't be faithfully expressed as a merge patch, so the caller should\n\t// publish a full snapshot instead. This happens when a value is set to null, which merge\n\t// patch reads as a key deletion. Arrays are fine: merge patch replaces them wholesale, which\n\t// is still typically smaller than a full snapshot.\n\tforcedSnapshot: boolean;\n}\n\ntype Obj = Record<string, unknown>;\n\nfunction isObject(value: unknown): value is Obj {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Generate an RFC 7396 merge patch transforming `oldVal` into `newVal`.\n *\n * Only object roots produce a recursive patch; any other root forces a snapshot.\n */\nexport function diff(oldVal: unknown, newVal: unknown): Diff {\n\tif (isObject(oldVal) && isObject(newVal)) {\n\t\tconst patch: Obj = {};\n\t\tconst forced = { value: false };\n\t\tdiffObjects(oldVal, newVal, patch, forced);\n\t\treturn { patch, forcedSnapshot: forced.value };\n\t}\n\treturn { patch: newVal, forcedSnapshot: true };\n}\n\nfunction diffObjects(oldObj: Obj, newObj: Obj, patch: Obj, forced: { value: boolean }): void {\n\t// Keys present in old but missing from new become explicit null deletions.\n\tfor (const key of Object.keys(oldObj)) {\n\t\tif (!(key in newObj)) patch[key] = null;\n\t}\n\n\tfor (const key of Object.keys(newObj)) {\n\t\tconst newV = newObj[key];\n\t\tconst oldV = oldObj[key];\n\t\tconst inOld = key in oldObj;\n\n\t\tif (inOld && deepEqual(oldV, newV)) continue;\n\n\t\t// Recurse into nested objects so unchanged sibling keys stay out of the patch.\n\t\tif (isObject(oldV) && isObject(newV)) {\n\t\t\tconst sub: Obj = {};\n\t\t\tdiffObjects(oldV, newV, sub, forced);\n\t\t\tif (Object.keys(sub).length > 0) patch[key] = sub;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Added or replaced with a non-object value. A literal null can't be stored: merge patch\n\t\t// would delete the key. Arrays are kept in the patch and replace the target wholesale.\n\t\tif (newV === null) {\n\t\t\tforced.value = true;\n\t\t}\n\t\tpatch[key] = newV;\n\t}\n}\n\n/**\n * Apply an RFC 7396 merge patch to a target value, returning the result.\n *\n * Objects merge recursively; a null patch value deletes that key; any other patch value\n * (scalar or array) replaces the target wholesale.\n */\nexport function merge(target: unknown, patch: unknown): unknown {\n\tif (!isObject(patch)) return patch;\n\n\tconst base: Obj = isObject(target) ? { ...target } : {};\n\tfor (const [key, value] of Object.entries(patch)) {\n\t\tif (value === null) {\n\t\t\tdelete base[key];\n\t\t} else {\n\t\t\tbase[key] = merge(base[key], value);\n\t\t}\n\t}\n\treturn base;\n}\n\n/** Structural equality for JSON values. */\nexport function deepEqual(a: unknown, b: unknown): boolean {\n\tif (a === b) return true;\n\n\tif (Array.isArray(a) && Array.isArray(b)) {\n\t\treturn a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));\n\t}\n\n\tif (isObject(a) && isObject(b)) {\n\t\tconst keys = Object.keys(a);\n\t\tif (keys.length !== Object.keys(b).length) return false;\n\t\treturn keys.every((key) => key in b && deepEqual(a[key], b[key]));\n\t}\n\n\treturn false;\n}\n"]}
package/diff.test.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=diff.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.test.d.ts","sourceRoot":"","sources":["../src/diff.test.ts"],"names":[],"mappings":""}
package/diff.test.js ADDED
@@ -0,0 +1,60 @@
1
+ /* @ts-self-types="./diff.test.d.ts" */
2
+ import { expect, test } from "bun:test";
3
+ import { deepEqual, diff, merge } from "./diff.js";
4
+ // Applying the patch to old should reproduce new (RFC 7396 semantics).
5
+ function assertRoundtrip(oldVal, newVal) {
6
+ const result = diff(oldVal, newVal);
7
+ expect(result.forcedSnapshot).toBe(false);
8
+ expect(merge(oldVal, result.patch)).toEqual(newVal);
9
+ }
10
+ test("changed scalar", () => {
11
+ assertRoundtrip({ a: 1, b: 2 }, { a: 1, b: 3 });
12
+ });
13
+ test("added key", () => {
14
+ const result = diff({ a: 1 }, { a: 1, b: 2 });
15
+ expect(result.forcedSnapshot).toBe(false);
16
+ expect(result.patch).toEqual({ b: 2 });
17
+ });
18
+ test("removed key is null", () => {
19
+ const result = diff({ a: 1, b: 2 }, { a: 1 });
20
+ expect(result.forcedSnapshot).toBe(false);
21
+ expect(result.patch).toEqual({ b: null });
22
+ assertRoundtrip({ a: 1, b: 2 }, { a: 1 });
23
+ });
24
+ test("nested object only includes changed keys", () => {
25
+ const result = diff({ o: { x: 1, y: 2 } }, { o: { x: 1, y: 9 } });
26
+ expect(result.forcedSnapshot).toBe(false);
27
+ expect(result.patch).toEqual({ o: { y: 9 } });
28
+ });
29
+ test("changed array is a wholesale delta", () => {
30
+ const result = diff({ a: [1, 2] }, { a: [1, 2, 3] });
31
+ expect(result.forcedSnapshot).toBe(false);
32
+ expect(result.patch).toEqual({ a: [1, 2, 3] });
33
+ assertRoundtrip({ a: [1, 2] }, { a: [1, 2, 3] });
34
+ });
35
+ test("added array is a delta", () => {
36
+ const result = diff({ a: 1 }, { a: 1, b: [1] });
37
+ expect(result.forcedSnapshot).toBe(false);
38
+ expect(result.patch).toEqual({ b: [1] });
39
+ });
40
+ test("nested array is a delta", () => {
41
+ const result = diff({ o: { x: 1 } }, { o: { x: 1, list: [1] } });
42
+ expect(result.forcedSnapshot).toBe(false);
43
+ expect(result.patch).toEqual({ o: { list: [1] } });
44
+ assertRoundtrip({ o: { x: 1 } }, { o: { x: 1, list: [1] } });
45
+ });
46
+ test("set to null forces snapshot", () => {
47
+ expect(diff({ a: 1 }, { a: null }).forcedSnapshot).toBe(true);
48
+ });
49
+ test("replacing object with scalar", () => {
50
+ assertRoundtrip({ a: { x: 1 } }, { a: 5 });
51
+ });
52
+ test("non-object root forces snapshot", () => {
53
+ expect(diff(1, 2).forcedSnapshot).toBe(true);
54
+ });
55
+ test("deepEqual", () => {
56
+ expect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);
57
+ expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
58
+ expect(deepEqual([1, 2], [1, 2, 3])).toBe(false);
59
+ });
60
+ //# sourceMappingURL=diff.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.test.js","sourceRoot":"","sources":["../src/diff.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { expect, test } from \"bun:test\";\nimport { deepEqual, diff, merge } from \"./diff.ts\";\n\n// Applying the patch to old should reproduce new (RFC 7396 semantics).\nfunction assertRoundtrip(oldVal: unknown, newVal: unknown) {\n\tconst result = diff(oldVal, newVal);\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(merge(oldVal, result.patch)).toEqual(newVal as object);\n}\n\ntest(\"changed scalar\", () => {\n\tassertRoundtrip({ a: 1, b: 2 }, { a: 1, b: 3 });\n});\n\ntest(\"added key\", () => {\n\tconst result = diff({ a: 1 }, { a: 1, b: 2 });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ b: 2 });\n});\n\ntest(\"removed key is null\", () => {\n\tconst result = diff({ a: 1, b: 2 }, { a: 1 });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ b: null });\n\tassertRoundtrip({ a: 1, b: 2 }, { a: 1 });\n});\n\ntest(\"nested object only includes changed keys\", () => {\n\tconst result = diff({ o: { x: 1, y: 2 } }, { o: { x: 1, y: 9 } });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ o: { y: 9 } });\n});\n\ntest(\"changed array is a wholesale delta\", () => {\n\tconst result = diff({ a: [1, 2] }, { a: [1, 2, 3] });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ a: [1, 2, 3] });\n\tassertRoundtrip({ a: [1, 2] }, { a: [1, 2, 3] });\n});\n\ntest(\"added array is a delta\", () => {\n\tconst result = diff({ a: 1 }, { a: 1, b: [1] });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ b: [1] });\n});\n\ntest(\"nested array is a delta\", () => {\n\tconst result = diff({ o: { x: 1 } }, { o: { x: 1, list: [1] } });\n\texpect(result.forcedSnapshot).toBe(false);\n\texpect(result.patch).toEqual({ o: { list: [1] } });\n\tassertRoundtrip({ o: { x: 1 } }, { o: { x: 1, list: [1] } });\n});\n\ntest(\"set to null forces snapshot\", () => {\n\texpect(diff({ a: 1 }, { a: null }).forcedSnapshot).toBe(true);\n});\n\ntest(\"replacing object with scalar\", () => {\n\tassertRoundtrip({ a: { x: 1 } }, { a: 5 });\n});\n\ntest(\"non-object root forces snapshot\", () => {\n\texpect(diff(1, 2).forcedSnapshot).toBe(true);\n});\n\ntest(\"deepEqual\", () => {\n\texpect(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);\n\texpect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);\n\texpect(deepEqual([1, 2], [1, 2, 3])).toBe(false);\n});\n"]}
package/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * JSON publishing over MoQ tracks, in two modes:
3
+ *
4
+ * - {@link Snapshot}: **lossy**. One JSON value updated over time; a consumer only gets the most
5
+ * recent value. Intermediate updates are collapsed and older groups are dropped.
6
+ * - {@link Stream}: **lossless**. An ordered append-log of self-contained records; every record
7
+ * is preserved and delivered in order, nothing is ever superseded.
8
+ *
9
+ * Pick {@link Snapshot} when consumers care about "what is the value now" (a catalog, a status
10
+ * document) and {@link Stream} when they care about every record (an event log, a media timeline).
11
+ *
12
+ * @module
13
+ */
14
+ export { type Diff, deepEqual, diff, merge } from "./diff";
15
+ export * as Snapshot from "./snapshot/index";
16
+ export * as Stream from "./stream";
17
+ //# sourceMappingURL=index.d.ts.map
package/index.d.ts.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,KAAK,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAC9D,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC;AAChD,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC"}
package/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /* @ts-self-types="./index.d.ts" */
2
+ /**
3
+ * JSON publishing over MoQ tracks, in two modes:
4
+ *
5
+ * - {@link Snapshot}: **lossy**. One JSON value updated over time; a consumer only gets the most
6
+ * recent value. Intermediate updates are collapsed and older groups are dropped.
7
+ * - {@link Stream}: **lossless**. An ordered append-log of self-contained records; every record
8
+ * is preserved and delivered in order, nothing is ever superseded.
9
+ *
10
+ * Pick {@link Snapshot} when consumers care about "what is the value now" (a catalog, a status
11
+ * document) and {@link Stream} when they care about every record (an event log, a media timeline).
12
+ *
13
+ * @module
14
+ */
15
+ export { deepEqual, diff, merge } from "./diff.js";
16
+ import * as Snapshot_1 from "./snapshot/index.js";
17
+ export { Snapshot_1 as Snapshot };
18
+ import * as Stream_1 from "./stream.js";
19
+ export { Stream_1 as Stream };
20
+ //# sourceMappingURL=index.js.map
package/index.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAa,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;4BACpC,qBAAqB;uBAAnC,QAAQ;0BACI,aAAa;qBAAzB,MAAM","sourcesContent":["/**\n * JSON publishing over MoQ tracks, in two modes:\n *\n * - {@link Snapshot}: **lossy**. One JSON value updated over time; a consumer only gets the most\n * recent value. Intermediate updates are collapsed and older groups are dropped.\n * - {@link Stream}: **lossless**. An ordered append-log of self-contained records; every record\n * is preserved and delivered in order, nothing is ever superseded.\n *\n * Pick {@link Snapshot} when consumers care about \"what is the value now\" (a catalog, a status\n * document) and {@link Stream} when they care about every record (an event log, a media timeline).\n *\n * @module\n */\n\nexport { type Diff, deepEqual, diff, merge } from \"./diff.ts\";\nexport * as Snapshot from \"./snapshot/index.ts\";\nexport * as Stream from \"./stream.ts\";\n"]}
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@norskvideo/moq-json",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "JSON publishing over MoQ tracks: snapshot/delta (RFC 7396 merge patch) objects, or append-log NDJSON streams.",
6
+ "license": "(MIT OR Apache-2.0)",
7
+ "repository": "github:moq-dev/moq",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@norskvideo/moq-flate": "^0.1.0",
17
+ "@norskvideo/moq-net": "^0.1.0",
18
+ "@norskvideo/moq-signals": "^0.1.0"
19
+ },
20
+ "peerDependencies": {
21
+ "zod": "^4.0.0"
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compression.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compression.test.d.ts","sourceRoot":"","sources":["../../src/snapshot/compression.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,132 @@
1
+ /* @ts-self-types="./compression.test.d.ts" */
2
+ import { expect, test } from "bun:test";
3
+ import { Decoder } from "@norskvideo/moq-flate";
4
+ import { Track } from "@norskvideo/moq-net";
5
+ import { Consumer } from "./consumer.js";
6
+ import { Producer } from "./producer.js";
7
+ const enc = new TextEncoder();
8
+ const dec = new TextDecoder();
9
+ // Reconstruct every value a compressed consumer yields, in order.
10
+ async function drainCompressed(track) {
11
+ const out = [];
12
+ for await (const value of new Consumer(track, { compression: true }))
13
+ out.push(value);
14
+ return out;
15
+ }
16
+ // The raw (stored) bytes of a track's first frame, without reconstructing JSON.
17
+ async function firstFrame(track) {
18
+ const group = await track.nextGroupOrdered();
19
+ if (!group)
20
+ throw new Error("expected a group");
21
+ const frame = await group.readFrame();
22
+ if (!frame)
23
+ throw new Error("expected a frame");
24
+ return frame;
25
+ }
26
+ // Count the groups a (finished) track published, draining each so the reads terminate.
27
+ async function groupCount(track) {
28
+ let groups = 0;
29
+ for (;;) {
30
+ const group = await track.nextGroupOrdered();
31
+ if (!group)
32
+ return groups;
33
+ groups++;
34
+ while ((await group.readFrame()) !== undefined) { }
35
+ }
36
+ }
37
+ test("compressed snapshot per group round-trips", async () => {
38
+ const track = new Track("test");
39
+ const producer = new Producer(track, { deltaRatio: 0, compression: true });
40
+ producer.update({ a: 1 });
41
+ producer.update({ a: 2 });
42
+ producer.finish();
43
+ // Deltas off: one compressed snapshot per group, reconstructed in order.
44
+ expect(await drainCompressed(track)).toEqual([{ a: 1 }, { a: 2 }]);
45
+ });
46
+ test("compressed live consumer sees each update in order", async () => {
47
+ // A live consumer reconstructs each update in order from the shared per-group stream.
48
+ const track = new Track("test");
49
+ const producer = new Producer(track, { deltaRatio: 100, compression: true });
50
+ const consumer = new Consumer(track, { compression: true });
51
+ for (let n = 1; n <= 5; n++) {
52
+ producer.update({ a: n });
53
+ expect(await consumer.next()).toEqual({ a: n });
54
+ }
55
+ });
56
+ test("compressed deltas share one group and reconstruct", async () => {
57
+ const track = new Track("test");
58
+ const producer = new Producer(track, { deltaRatio: 100, compression: true });
59
+ producer.update({ a: 1, b: 1 });
60
+ producer.update({ a: 1, b: 2 });
61
+ producer.update({ a: 5, b: 2 });
62
+ producer.finish();
63
+ expect((await drainCompressed(track)).at(-1)).toEqual({ a: 5, b: 2 });
64
+ });
65
+ test("compressed late joiner reconstructs from snapshot + deltas", async () => {
66
+ const track = new Track("test");
67
+ const producer = new Producer(track, { deltaRatio: 100, compression: true });
68
+ producer.update({ a: 1, b: 1 });
69
+ producer.update({ a: 1, b: 2 });
70
+ producer.update({ a: 5, b: 2 });
71
+ producer.finish();
72
+ // A consumer created only now still rebuilds the final value from the snapshot + deltas.
73
+ expect((await drainCompressed(track)).at(-1)).toEqual({ a: 5, b: 2 });
74
+ });
75
+ test("a group's snapshot decodes from a fresh decoder", async () => {
76
+ // Frame 0 opens a cold window, so a brand-new decoder reconstructs it, which is what lets a late
77
+ // joiner (or the Rust consumer) start mid-stream at any group boundary.
78
+ const track = new Track("test");
79
+ const producer = new Producer(track, { deltaRatio: 0, compression: true });
80
+ producer.update({ hello: "world" });
81
+ producer.finish();
82
+ const decoder = new Decoder();
83
+ expect(JSON.parse(dec.decode(decoder.frame(await firstFrame(track))))).toEqual({ hello: "world" });
84
+ });
85
+ test("compressed deltas reuse the window", async () => {
86
+ // The shared per-group window is the point: a delta restating snapshot content shrinks sharply.
87
+ const track = new Track("test");
88
+ const producer = new Producer(track, { deltaRatio: 100, compression: true });
89
+ const phrase = "Media over QUIC delivers real-time latency at massive scale";
90
+ producer.update({ note: phrase });
91
+ producer.update({ note: phrase, echo: phrase });
92
+ producer.finish();
93
+ const group = await track.nextGroupOrdered();
94
+ if (!group)
95
+ throw new Error("expected a group");
96
+ await group.readFrame(); // snapshot
97
+ const delta = await group.readFrame();
98
+ if (!delta)
99
+ throw new Error("expected a delta");
100
+ const rawDelta = enc.encode(JSON.stringify({ echo: phrase }));
101
+ expect(delta.length).toBeLessThan(rawDelta.length / 2);
102
+ });
103
+ test("compression shrinks a repetitive frame", async () => {
104
+ const value = { renditions: Array(3).fill("video".repeat(50)) };
105
+ const plain = new Track("plain");
106
+ new Producer(plain, { deltaRatio: 0 }).update(value);
107
+ const compressed = new Track("compressed");
108
+ new Producer(compressed, { deltaRatio: 0, compression: true }).update(value);
109
+ const plainLen = (await firstFrame(plain)).length;
110
+ const compressedLen = (await firstFrame(compressed)).length;
111
+ expect(compressedLen).toBeLessThan(plainLen);
112
+ });
113
+ test("compressed deltas roll on the compressed budget", async () => {
114
+ // With compression the budget is measured on compressed frame sizes: #snapshotLen and #deltaBytes
115
+ // are the written slice lengths, not the raw JSON. A tight ratio over many distinct updates must
116
+ // roll more than one group, and a late joiner must still rebuild the final value across the
117
+ // compressed group boundary. Guards against the budget regressing to raw lengths. Two identical
118
+ // producers (deterministic output) keep the group-count and reconstruction reads independent.
119
+ const fill = (track) => {
120
+ const producer = new Producer(track, { deltaRatio: 2, compression: true });
121
+ for (let n = 0; n <= 40; n++)
122
+ producer.update({ n });
123
+ producer.finish();
124
+ };
125
+ const layout = new Track("layout");
126
+ fill(layout);
127
+ expect(await groupCount(layout)).toBeGreaterThan(1);
128
+ const reconstruct = new Track("reconstruct");
129
+ fill(reconstruct);
130
+ expect((await drainCompressed(reconstruct)).at(-1)).toEqual({ n: 40 });
131
+ });
132
+ //# sourceMappingURL=compression.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compression.test.js","sourceRoot":"","sources":["../../src/snapshot/compression.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { expect, test } from \"bun:test\";\nimport { Decoder } from \"@moq/flate\";\nimport { Track } from \"@moq/net\";\nimport { Consumer } from \"./consumer.ts\";\nimport { Producer } from \"./producer.ts\";\n\ntype Value = Record<string, unknown>;\n\nconst enc = new TextEncoder();\nconst dec = new TextDecoder();\n\n// Reconstruct every value a compressed consumer yields, in order.\nasync function drainCompressed(track: Track): Promise<Value[]> {\n\tconst out: Value[] = [];\n\tfor await (const value of new Consumer<Value>(track, { compression: true })) out.push(value);\n\treturn out;\n}\n\n// The raw (stored) bytes of a track's first frame, without reconstructing JSON.\nasync function firstFrame(track: Track): Promise<Uint8Array> {\n\tconst group = await track.nextGroupOrdered();\n\tif (!group) throw new Error(\"expected a group\");\n\tconst frame = await group.readFrame();\n\tif (!frame) throw new Error(\"expected a frame\");\n\treturn frame;\n}\n\n// Count the groups a (finished) track published, draining each so the reads terminate.\nasync function groupCount(track: Track): Promise<number> {\n\tlet groups = 0;\n\tfor (;;) {\n\t\tconst group = await track.nextGroupOrdered();\n\t\tif (!group) return groups;\n\t\tgroups++;\n\t\twhile ((await group.readFrame()) !== undefined) {}\n\t}\n}\n\ntest(\"compressed snapshot per group round-trips\", async () => {\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 0, compression: true });\n\tproducer.update({ a: 1 });\n\tproducer.update({ a: 2 });\n\tproducer.finish();\n\n\t// Deltas off: one compressed snapshot per group, reconstructed in order.\n\texpect(await drainCompressed(track)).toEqual([{ a: 1 }, { a: 2 }]);\n});\n\ntest(\"compressed live consumer sees each update in order\", async () => {\n\t// A live consumer reconstructs each update in order from the shared per-group stream.\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 100, compression: true });\n\tconst consumer = new Consumer<Value>(track, { compression: true });\n\n\tfor (let n = 1; n <= 5; n++) {\n\t\tproducer.update({ a: n });\n\t\texpect(await consumer.next()).toEqual({ a: n });\n\t}\n});\n\ntest(\"compressed deltas share one group and reconstruct\", async () => {\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 100, compression: true });\n\tproducer.update({ a: 1, b: 1 });\n\tproducer.update({ a: 1, b: 2 });\n\tproducer.update({ a: 5, b: 2 });\n\tproducer.finish();\n\n\texpect((await drainCompressed(track)).at(-1)).toEqual({ a: 5, b: 2 });\n});\n\ntest(\"compressed late joiner reconstructs from snapshot + deltas\", async () => {\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 100, compression: true });\n\tproducer.update({ a: 1, b: 1 });\n\tproducer.update({ a: 1, b: 2 });\n\tproducer.update({ a: 5, b: 2 });\n\tproducer.finish();\n\n\t// A consumer created only now still rebuilds the final value from the snapshot + deltas.\n\texpect((await drainCompressed(track)).at(-1)).toEqual({ a: 5, b: 2 });\n});\n\ntest(\"a group's snapshot decodes from a fresh decoder\", async () => {\n\t// Frame 0 opens a cold window, so a brand-new decoder reconstructs it, which is what lets a late\n\t// joiner (or the Rust consumer) start mid-stream at any group boundary.\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 0, compression: true });\n\tproducer.update({ hello: \"world\" });\n\tproducer.finish();\n\n\tconst decoder = new Decoder();\n\texpect(JSON.parse(dec.decode(decoder.frame(await firstFrame(track))))).toEqual({ hello: \"world\" });\n});\n\ntest(\"compressed deltas reuse the window\", async () => {\n\t// The shared per-group window is the point: a delta restating snapshot content shrinks sharply.\n\tconst track = new Track(\"test\");\n\tconst producer = new Producer<Value>(track, { deltaRatio: 100, compression: true });\n\tconst phrase = \"Media over QUIC delivers real-time latency at massive scale\";\n\tproducer.update({ note: phrase });\n\tproducer.update({ note: phrase, echo: phrase });\n\tproducer.finish();\n\n\tconst group = await track.nextGroupOrdered();\n\tif (!group) throw new Error(\"expected a group\");\n\tawait group.readFrame(); // snapshot\n\tconst delta = await group.readFrame();\n\tif (!delta) throw new Error(\"expected a delta\");\n\n\tconst rawDelta = enc.encode(JSON.stringify({ echo: phrase }));\n\texpect(delta.length).toBeLessThan(rawDelta.length / 2);\n});\n\ntest(\"compression shrinks a repetitive frame\", async () => {\n\tconst value = { renditions: Array(3).fill(\"video\".repeat(50)) };\n\n\tconst plain = new Track(\"plain\");\n\tnew Producer<Value>(plain, { deltaRatio: 0 }).update(value);\n\tconst compressed = new Track(\"compressed\");\n\tnew Producer<Value>(compressed, { deltaRatio: 0, compression: true }).update(value);\n\n\tconst plainLen = (await firstFrame(plain)).length;\n\tconst compressedLen = (await firstFrame(compressed)).length;\n\texpect(compressedLen).toBeLessThan(plainLen);\n});\n\ntest(\"compressed deltas roll on the compressed budget\", async () => {\n\t// With compression the budget is measured on compressed frame sizes: #snapshotLen and #deltaBytes\n\t// are the written slice lengths, not the raw JSON. A tight ratio over many distinct updates must\n\t// roll more than one group, and a late joiner must still rebuild the final value across the\n\t// compressed group boundary. Guards against the budget regressing to raw lengths. Two identical\n\t// producers (deterministic output) keep the group-count and reconstruction reads independent.\n\tconst fill = (track: Track) => {\n\t\tconst producer = new Producer<Value>(track, { deltaRatio: 2, compression: true });\n\t\tfor (let n = 0; n <= 40; n++) producer.update({ n });\n\t\tproducer.finish();\n\t};\n\n\tconst layout = new Track(\"layout\");\n\tfill(layout);\n\texpect(await groupCount(layout)).toBeGreaterThan(1);\n\n\tconst reconstruct = new Track(\"reconstruct\");\n\tfill(reconstruct);\n\texpect((await drainCompressed(reconstruct)).at(-1)).toEqual({ n: 40 });\n});\n"]}
@@ -0,0 +1,25 @@
1
+ import type * as Moq from "@norskvideo/moq-net";
2
+ import type { Config } from "./producer";
3
+ /**
4
+ * Consumes a JSON value from a track, reconstructing it from snapshots and deltas.
5
+ *
6
+ * Reads each group's snapshot (frame 0) and applies the following frames as merge patches. A live
7
+ * consumer yields each update as it arrives; a consumer that has fallen behind (or just joined)
8
+ * collapses the buffered backlog and yields only the latest value. See {@link next}.
9
+ */
10
+ export declare class Consumer<T> {
11
+ #private;
12
+ constructor(track: Moq.Track, config?: Config<T>);
13
+ /**
14
+ * Get the next reconstructed value, or `undefined` once the track ends.
15
+ *
16
+ * Applies every frame already buffered in the group but yields only the latest reconstructed
17
+ * value: the intermediate reconstructions are stale, so a late joiner (or any consumer that has
18
+ * fallen behind) catches up to the head in one step instead of replaying every superseded state.
19
+ * Frames are still decoded in order (the DEFLATE window and merge patches are sequential); only
20
+ * the per-frame yield is skipped.
21
+ */
22
+ next(): Promise<T | undefined>;
23
+ [Symbol.asyncIterator](): AsyncIterator<T>;
24
+ }
25
+ //# sourceMappingURL=consumer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consumer.d.ts","sourceRoot":"","sources":["../../src/snapshot/consumer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAGrC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;;;;GAMG;AACH,qBAAa,QAAQ,CAAC,CAAC;;IAYtB,YAAY,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,MAAM,GAAE,MAAM,CAAC,CAAC,CAAM,EAInD;IAED;;;;;;;;OAQG;IACG,IAAI,IAAI,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CA+BnC;IAEM,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,CAMhD;CAoBD"}
@@ -0,0 +1,93 @@
1
+ /* @ts-self-types="./consumer.d.ts" */
2
+ import { Decoder } from "@norskvideo/moq-flate";
3
+ import { merge } from "../diff.js";
4
+ /**
5
+ * Consumes a JSON value from a track, reconstructing it from snapshots and deltas.
6
+ *
7
+ * Reads each group's snapshot (frame 0) and applies the following frames as merge patches. A live
8
+ * consumer yields each update as it arrives; a consumer that has fallen behind (or just joined)
9
+ * collapses the buffered backlog and yields only the latest value. See {@link next}.
10
+ */
11
+ export class Consumer {
12
+ #track;
13
+ #schema;
14
+ // Whether frames are `deflate-raw` compressed. Must match the producer's {@link Config.compression}.
15
+ #decompress;
16
+ #group;
17
+ // Per-group DEFLATE decoder, built lazily on the first frame of a group and reset at each boundary.
18
+ #decoder;
19
+ #current;
20
+ #framesRead = 0;
21
+ constructor(track, config = {}) {
22
+ this.#track = track;
23
+ this.#schema = config.schema;
24
+ this.#decompress = config.compression ?? false;
25
+ }
26
+ /**
27
+ * Get the next reconstructed value, or `undefined` once the track ends.
28
+ *
29
+ * Applies every frame already buffered in the group but yields only the latest reconstructed
30
+ * value: the intermediate reconstructions are stale, so a late joiner (or any consumer that has
31
+ * fallen behind) catches up to the head in one step instead of replaying every superseded state.
32
+ * Frames are still decoded in order (the DEFLATE window and merge patches are sequential); only
33
+ * the per-frame yield is skipped.
34
+ */
35
+ async next() {
36
+ for (;;) {
37
+ if (!this.#group) {
38
+ // Advance to the next group with a higher sequence number (skipping late arrivals).
39
+ this.#group = await this.#track.nextGroupOrdered();
40
+ if (!this.#group)
41
+ return undefined;
42
+ this.#current = undefined;
43
+ this.#framesRead = 0;
44
+ // Each group is its own compressed stream, so start a fresh decoder.
45
+ this.#decoder = undefined;
46
+ }
47
+ // Drain every frame already buffered, keeping only the latest reconstructed value.
48
+ let value;
49
+ let advanced = false;
50
+ for (let frame = this.#group.tryReadFrame(); frame !== undefined; frame = this.#group.tryReadFrame()) {
51
+ value = this.#apply(frame);
52
+ advanced = true;
53
+ }
54
+ if (advanced)
55
+ return value;
56
+ // Nothing buffered: block for the next frame (or the group's end).
57
+ const frame = await this.#group.readFrame();
58
+ if (frame === undefined) {
59
+ // The group is exhausted; advance to the next one.
60
+ this.#group = undefined;
61
+ continue;
62
+ }
63
+ return this.#apply(frame);
64
+ }
65
+ }
66
+ async *[Symbol.asyncIterator]() {
67
+ for (;;) {
68
+ const value = await this.next();
69
+ if (value === undefined)
70
+ return;
71
+ yield value;
72
+ }
73
+ }
74
+ // Frame 0 of a group is a snapshot, the rest are merge patches. When compressed, frames share one
75
+ // per-group DEFLATE stream, so they decode in order through a decoder built on the group's first frame.
76
+ #apply(frame) {
77
+ let payload = frame;
78
+ if (this.#decompress) {
79
+ this.#decoder ??= new Decoder();
80
+ payload = this.#decoder.frame(frame);
81
+ }
82
+ const parsed = JSON.parse(new TextDecoder().decode(payload));
83
+ if (this.#framesRead === 0) {
84
+ this.#current = parsed;
85
+ }
86
+ else {
87
+ this.#current = merge(this.#current, parsed);
88
+ }
89
+ this.#framesRead += 1;
90
+ return this.#schema ? this.#schema.parse(this.#current) : this.#current;
91
+ }
92
+ }
93
+ //# sourceMappingURL=consumer.js.map