wenay-common2 1.0.70 → 1.0.74

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 (39) hide show
  1. package/README.md +5 -3
  2. package/demo/client.ts +230 -3
  3. package/demo/index.html +37 -0
  4. package/demo/server.ts +91 -4
  5. package/doc/INTENT.md +31 -0
  6. package/doc/ROADMAP.md +233 -212
  7. package/doc/changes/1.0.71.md +9 -0
  8. package/doc/changes/1.0.72.md +6 -0
  9. package/doc/changes/1.0.73.md +6 -0
  10. package/doc/changes/1.0.74.md +12 -0
  11. package/doc/wenay-common2-rare.md +684 -627
  12. package/doc/wenay-common2.md +50 -5
  13. package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
  14. package/lib/Common/events/route-signal-webrtc.js +15 -5
  15. package/lib/Common/media/media-index.d.ts +1 -0
  16. package/lib/Common/media/media-index.js +1 -0
  17. package/lib/Common/media/media-source.d.ts +3 -0
  18. package/lib/Common/media/media-source.js +136 -27
  19. package/lib/Common/media/media-view.d.ts +42 -0
  20. package/lib/Common/media/media-view.js +210 -0
  21. package/lib/Common/peer/peer-call.d.ts +65 -0
  22. package/lib/Common/peer/peer-call.js +173 -0
  23. package/lib/Common/peer/peer-client.d.ts +21 -5
  24. package/lib/Common/peer/peer-client.js +53 -3
  25. package/lib/Common/peer/peer-host.d.ts +22 -5
  26. package/lib/Common/peer/peer-host.js +49 -5
  27. package/lib/Common/peer/peer-index.d.ts +2 -0
  28. package/lib/Common/peer/peer-index.js +2 -0
  29. package/lib/Common/peer/peer-media-relay.d.ts +22 -0
  30. package/lib/Common/peer/peer-media-relay.js +155 -0
  31. package/lib/Common/peer/peer-relay.d.ts +10 -2
  32. package/lib/Common/peer/peer-relay.js +40 -18
  33. package/observe/listen-core.test.ts +1 -2
  34. package/package.json +1 -4
  35. package/replay/media-view.test.ts +164 -0
  36. package/replay/peer-call.test.ts +226 -0
  37. package/replay/peer-repair.test.ts +184 -0
  38. package/doc/changes/1.0.63.md +0 -8
  39. package/doc/changes/1.0.64.md +0 -10
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMediaRelay = createMediaRelay;
4
+ const replay_listen_1 = require("../events/replay-listen");
5
+ const rpc_dynamic_1 = require("../rcp/rpc-dynamic");
6
+ function createMediaRelay(deps) {
7
+ const { lines, videoHistory = 8, audioHistory = 64, canWatch } = deps;
8
+ const accounts = new Map();
9
+ const watch = (0, rpc_dynamic_1.noStrict)({});
10
+ function makeLine(kind) {
11
+ return kind == 'video'
12
+ ? (0, replay_listen_1.replayListen)({
13
+ history: videoHistory, current: 'last',
14
+ frame: function lastFrameOnly(tail) { return tail.length ? [tail[tail.length - 1]] : []; },
15
+ })
16
+ : (0, replay_listen_1.replayListen)({ history: audioHistory });
17
+ }
18
+ function linesOf(account) {
19
+ let entry = accounts.get(account);
20
+ if (!entry) {
21
+ entry = { emits: {}, view: (0, rpc_dynamic_1.noStrict)({}) };
22
+ for (const name of Object.keys(lines)) {
23
+ const [emit, api] = makeLine(lines[name]);
24
+ entry.emits[name] = emit;
25
+ entry.view[name] = api;
26
+ }
27
+ accounts.set(account, entry);
28
+ watch[account] = entry.view;
29
+ }
30
+ return entry;
31
+ }
32
+ function publishOf(account) {
33
+ linesOf(account);
34
+ return function publish(line, frame, sentAt) {
35
+ linesOf(account).emits[line]?.(frame, sentAt);
36
+ };
37
+ }
38
+ const watcherViews = new Map();
39
+ function allowed(watcher, owner, line) {
40
+ return canWatch ? canWatch(watcher, owner, line) : true;
41
+ }
42
+ function filteredLine(watcher, owner, name, source, cache) {
43
+ const [emit, filtered] = (0, replay_listen_1.replayListen)({
44
+ history: lines[name] == 'video' ? videoHistory : audioHistory,
45
+ current: function currentIfAllowed() {
46
+ return allowed(watcher, owner, name) ? source.keyframe()?.event : undefined;
47
+ },
48
+ ...(lines[name] == 'video' ? {
49
+ frame: function lastAllowedFrame(tail) {
50
+ return tail.length ? [tail[tail.length - 1]] : [];
51
+ },
52
+ } : {}),
53
+ });
54
+ const offSource = source.on(function forwardAllowed(frame, sentAt) {
55
+ if (allowed(watcher, owner, name))
56
+ emit(frame, sentAt);
57
+ });
58
+ const closeFiltered = filtered.close;
59
+ filtered.close = function closePolicyLine() {
60
+ offSource();
61
+ closeFiltered();
62
+ };
63
+ cache.filtered.add(filtered);
64
+ return filtered;
65
+ }
66
+ function ownerViewFor(watcher, owner, cache) {
67
+ let view = cache.owners.get(owner);
68
+ if (view)
69
+ return view;
70
+ const entry = accounts.get(owner);
71
+ if (!entry)
72
+ return undefined;
73
+ const policyLines = {};
74
+ for (const name of Object.keys(lines))
75
+ policyLines[name] = filteredLine(watcher, owner, name, entry.view[name], cache);
76
+ view = (0, rpc_dynamic_1.noStrict)(new Proxy(policyLines, {
77
+ has(t, k) { return typeof k == 'string' && k in t && allowed(watcher, owner, k); },
78
+ get(t, k) {
79
+ if (typeof k != 'string')
80
+ return t[k];
81
+ return k in t && allowed(watcher, owner, k) ? t[k] : undefined;
82
+ },
83
+ }));
84
+ cache.owners.set(owner, view);
85
+ return view;
86
+ }
87
+ function watchOf(watcher) {
88
+ let cached = watcherViews.get(watcher);
89
+ if (cached)
90
+ return cached.root;
91
+ const owners = new Map();
92
+ const filtered = new Set();
93
+ function visible(owner) {
94
+ return accounts.has(owner) && Object.keys(lines).some(name => allowed(watcher, owner, name));
95
+ }
96
+ const root = (0, rpc_dynamic_1.noStrict)(new Proxy({}, {
97
+ has(_t, k) { return typeof k == 'string' && visible(k); },
98
+ get(_t, k) {
99
+ if (typeof k != 'string')
100
+ return undefined;
101
+ return visible(k) ? ownerViewFor(watcher, k, cached) : undefined;
102
+ },
103
+ }));
104
+ cached = { root, owners, filtered };
105
+ watcherViews.set(watcher, cached);
106
+ return root;
107
+ }
108
+ function dropLines(entry) {
109
+ for (const name of Object.keys(entry.view))
110
+ entry.view[name].close();
111
+ }
112
+ function closeWatcherCache(cache) {
113
+ for (const line of cache.filtered)
114
+ line.close();
115
+ cache.filtered.clear();
116
+ cache.owners.clear();
117
+ }
118
+ function dropAccount(account) {
119
+ const entry = accounts.get(account);
120
+ const ownCache = watcherViews.get(account);
121
+ if (ownCache)
122
+ closeWatcherCache(ownCache);
123
+ watcherViews.delete(account);
124
+ for (const cache of watcherViews.values()) {
125
+ const ownerView = cache.owners.get(account);
126
+ if (ownerView)
127
+ for (const line of Object.values(ownerView)) {
128
+ line.close();
129
+ cache.filtered.delete(line);
130
+ }
131
+ cache.owners.delete(account);
132
+ }
133
+ if (!entry)
134
+ return;
135
+ accounts.delete(account);
136
+ delete watch[account];
137
+ dropLines(entry);
138
+ }
139
+ return {
140
+ publishOf,
141
+ watch,
142
+ watchOf,
143
+ lines: (account) => linesOf(account).view,
144
+ accounts: () => Array.from(accounts.keys()),
145
+ dropAccount,
146
+ close() {
147
+ for (const cache of watcherViews.values())
148
+ closeWatcherCache(cache);
149
+ for (const entry of accounts.values())
150
+ dropLines(entry);
151
+ accounts.clear();
152
+ watcherViews.clear();
153
+ },
154
+ };
155
+ }
@@ -2,11 +2,19 @@ import { ReplayEvent } from '../events/replay-listen';
2
2
  import { ReplayRemote } from '../events/replay-wire';
3
3
  import { StorePatch } from '../Observe/store';
4
4
  export type PatchEnvelope = ReplayEvent<[StorePatch]>;
5
+ export type tRelayGap = 'resume' | 'sacred';
6
+ export type RelayPushResult = boolean | {
7
+ seq: number;
8
+ };
5
9
  export declare function createPatchRelayJournal(opts?: {
6
10
  history?: number;
11
+ gap?: tRelayGap;
7
12
  }): {
8
- push: (env: PatchEnvelope) => boolean;
9
- remote: ReplayRemote<[StorePatch]>;
13
+ push: (env: PatchEnvelope) => RelayPushResult;
14
+ remote: ReplayRemote<[StorePatch]> & {
15
+ seq: () => number;
16
+ };
17
+ gap: tRelayGap;
10
18
  seq: () => number;
11
19
  snapshot: () => any;
12
20
  close: () => void;
@@ -16,37 +16,51 @@ function condensePatchTail(tail) {
16
16
  return Array.from(held.values());
17
17
  }
18
18
  function createPatchRelayJournal(opts = {}) {
19
- const { history = 1024 } = opts;
19
+ const { history = 1024, gap = 'resume' } = opts;
20
+ const folding = gap != 'sacred';
20
21
  const [emitLine, line] = (0, Listen_1.listen)();
21
- const fold = (0, store_1.createStore)({}, { drain: 'micro' });
22
+ const fold = folding ? (0, store_1.createStore)({}, { drain: 'micro' }) : null;
22
23
  let ring = [];
23
24
  let last = -1;
24
25
  let lastTs = 0;
25
26
  let hasState = false;
26
- function push(env) {
27
- if (env == null || typeof env.seq != 'number')
28
- return false;
29
- const patch = env.event?.[0];
30
- if (patch == null || !Array.isArray(patch.path))
31
- return false;
32
- const isRoot = patch.path.length == 0;
33
- if (env.seq <= last) {
34
- if (!isRoot || env.seq == last)
35
- return false;
27
+ function accept(env, reset) {
28
+ if (reset)
36
29
  ring = [];
37
- }
38
30
  last = env.seq;
39
31
  lastTs = env.ts ?? lastTs;
40
32
  hasState = true;
41
- (0, store_1.applyStorePatch)(fold, patch);
33
+ if (fold)
34
+ (0, store_1.applyStorePatch)(fold, env.event[0]);
42
35
  ring.push(env);
43
36
  if (ring.length > history)
44
37
  ring.splice(0, ring.length - history);
45
38
  emitLine(env);
46
39
  return true;
47
40
  }
41
+ function push(env) {
42
+ if (env == null || typeof env.seq != 'number')
43
+ return false;
44
+ const patch = env.event?.[0];
45
+ if (patch == null || !Array.isArray(patch.path))
46
+ return false;
47
+ const isRoot = patch.path.length == 0;
48
+ if (env.seq <= last) {
49
+ if (folding && isRoot && env.seq < last)
50
+ return accept(env, true);
51
+ return true;
52
+ }
53
+ if (hasState && env.seq > last + 1) {
54
+ if (folding && isRoot)
55
+ return accept(env, true);
56
+ return { seq: last };
57
+ }
58
+ if (folding && !hasState && !isRoot)
59
+ return { seq: -1 };
60
+ return accept(env, false);
61
+ }
48
62
  function keyframe() {
49
- if (!hasState)
63
+ if (!fold || !hasState)
50
64
  return null;
51
65
  return { seq: last, ts: lastTs, event: [{ path: [], exists: true, value: fold.snapshot() }] };
52
66
  }
@@ -64,14 +78,22 @@ function createPatchRelayJournal(opts = {}) {
64
78
  if (tail)
65
79
  return condensePatchTail(tail);
66
80
  const kf = keyframe();
67
- return kf ? [kf] : null;
81
+ if (kf)
82
+ return [kf];
83
+ if (!folding && hasState)
84
+ throw new Error('sacred relay journal: tail evicted, no keyframe to invent');
85
+ return null;
68
86
  }
69
- const remote = { line, since, keyframe, frame };
87
+ const remote = {
88
+ line, since, keyframe, frame,
89
+ seq: () => last,
90
+ };
70
91
  return {
71
92
  push,
72
93
  remote,
94
+ gap,
73
95
  seq: () => last,
74
- snapshot: () => fold.snapshot(),
96
+ snapshot: () => fold?.snapshot(),
75
97
  close: () => { line.close(); },
76
98
  };
77
99
  }
@@ -62,5 +62,4 @@ async function main() {
62
62
  process.exit(fails == 0 ? 0 : 1)
63
63
  }
64
64
 
65
- main().catch(e => { console.error(e); process.exit(1) })
66
-
65
+ main().catch(e => { console.error(e); process.exit(1) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.70",
3
+ "version": "1.0.74",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
@@ -42,8 +42,5 @@
42
42
  "./package.json": "./package.json",
43
43
  "./media": "./lib/Common/media/media-index.js",
44
44
  "./peer": "./lib/Common/peer/peer-index.js"
45
- },
46
- "dependencies": {
47
- "express": "^5.2.1"
48
45
  }
49
46
  }
@@ -0,0 +1,164 @@
1
+ // ============================================================
2
+ // replay/media-view.test.ts
3
+ //
4
+ // Media viewer helpers oracle (Node, no browser):
5
+ // - attachAudioPlayer: PCM decode, sequential playhead, live
6
+ // backlog drop, enable/disable, non-audio frames ignored
7
+ // - attachVideoCanvas: injected bitmap decoder, sizing follows
8
+ // frames, busy-skip keeps latest, stats
9
+ // - pipeMediaPublish: sentAt stamp, onError on rejection
10
+ // ============================================================
11
+
12
+ import {listen} from '../src/Common/events/Listen'
13
+ import {
14
+ attachAudioPlayer,
15
+ attachVideoCanvas,
16
+ encodeMediaFrame,
17
+ pipeMediaPublish,
18
+ } from '../src/Common/media/media-index'
19
+
20
+ let fails = 0
21
+ const ok = (condition: any, message: string) => {
22
+ if (!condition) { fails++; console.log(' FAIL', message) }
23
+ else console.log(' OK ', message)
24
+ }
25
+ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
26
+
27
+ function pcmFrame(seq: number, nSamples = 480, sampleRate = 48000) {
28
+ const pcm = new Int16Array(nSamples)
29
+ for (let i = 0; i < nSamples; i++) pcm[i] = (i % 2 ? 1 : -1) * 1000
30
+ return encodeMediaFrame({kind: 'audio-pcm', codec: 'pcm16', seq, tMono: seq, sampleRate, channels: 1, nSamples}, new Uint8Array(pcm.buffer))
31
+ }
32
+
33
+ function videoFrame(seq: number, width: number, height: number) {
34
+ return encodeMediaFrame({kind: 'video-frame', codec: 'jpeg', seq, tMono: seq, width, height}, new Uint8Array([1, 2, 3]))
35
+ }
36
+
37
+ function createFakeAudioContext() {
38
+ const scheduled: {at: number, frames: number}[] = []
39
+ const ctx = {
40
+ currentTime: 0,
41
+ destination: {},
42
+ resumed: 0,
43
+ closed: 0,
44
+ resume() { ctx.resumed++ },
45
+ close() { ctx.closed++ },
46
+ createBuffer: (_ch: number, frames: number, _rate: number) => ({
47
+ frames,
48
+ getChannelData: () => new Float32Array(frames),
49
+ }),
50
+ createBufferSource: () => {
51
+ const node: any = {connect: () => {}, start: (at: number) => scheduled.push({at, frames: node.buffer.frames})}
52
+ return node
53
+ },
54
+ scheduled,
55
+ }
56
+ return ctx
57
+ }
58
+
59
+ async function main() {
60
+ console.log('\n[media-view] audio player: sequential playhead + live backlog drop')
61
+ {
62
+ const [emit, line] = listen<[Uint8Array, number]>()
63
+ const ctx = createFakeAudioContext()
64
+ const player = attachAudioPlayer(line as any, {audioContext: () => ctx as any})
65
+
66
+ emit(pcmFrame(1), Date.now())
67
+ ok(player.stats().frames == 1 && player.stats().played == 0, 'disabled player counts frames but schedules nothing')
68
+
69
+ player.enable()
70
+ ok(player.enabled && ctx.resumed == 1, 'enable() builds the injected context and resumes it')
71
+
72
+ emit(pcmFrame(2), Date.now() - 100)
73
+ ok(player.stats().played == 1 && ctx.scheduled.length == 1, 'enabled player schedules a decoded pcm16 buffer')
74
+ ok(ctx.scheduled[0].frames == 480, 'sample count survives decode (480 frames)')
75
+ ok(player.stats().ageMs >= 80, 'sentAt stamp turns into a real age measurement')
76
+
77
+ const before = ctx.scheduled.length
78
+ emit(videoFrame(3, 320, 240), Date.now())
79
+ ok(ctx.scheduled.length == before && player.stats().frames == 3, 'video frames on an audio player are counted, not scheduled')
80
+
81
+ // 40 x 10ms chunks pushed instantly = 0.4s of queued audio while currentTime stays 0
82
+ for (let i = 0; i < 40; i++) emit(pcmFrame(10 + i), Date.now())
83
+ ok(player.stats().dropped > 0, 'backlog past maxBacklogSec is dropped (live policy), not queued forever')
84
+ const last = ctx.scheduled[ctx.scheduled.length - 1]
85
+ ok(last.at <= 0.45, `playhead rebased near "now" after the drop (last at=${last.at.toFixed(2)}s)`)
86
+
87
+ player.disable()
88
+ ok(!player.enabled && ctx.closed == 1, 'disable() closes the context')
89
+ player.off()
90
+ }
91
+
92
+ console.log('\n[media-view] video canvas: sizing follows frames, busy-skip keeps latest')
93
+ {
94
+ const [emit, line] = listen<[Uint8Array, number]>()
95
+ const drawnImages: any[] = []
96
+ const canvas: any = {
97
+ width: 0,
98
+ height: 0,
99
+ getContext: () => ({drawImage: (img: any) => drawnImages.push(img)}),
100
+ }
101
+ let decodeDelay = 0
102
+ const view = attachVideoCanvas(line as any, canvas, {
103
+ createBitmap: async blob => {
104
+ if (decodeDelay) await delay(decodeDelay)
105
+ return {size: blob.size, close: () => {}}
106
+ },
107
+ })
108
+
109
+ emit(videoFrame(1, 640, 480), Date.now() - 100)
110
+ await delay(10)
111
+ ok(view.stats().drawn == 1 && drawnImages.length == 1, 'video frame decoded via injected bitmap factory and drawn')
112
+ ok(canvas.width == 640 && canvas.height == 480, 'canvas takes the frame size')
113
+ ok(view.stats().width == 640 && view.stats().ageMs >= 80, 'stats expose frame size and age')
114
+
115
+ emit(pcmFrame(2), Date.now())
116
+ await delay(10)
117
+ ok(view.stats().drawn == 1 && view.stats().frames == 2, 'audio frame on a video canvas is counted, not drawn')
118
+
119
+ decodeDelay = 30
120
+ emit(videoFrame(3, 1280, 720), Date.now())
121
+ emit(videoFrame(4, 1280, 720), Date.now()) // arrives while frame 3 decodes -> skipped
122
+ await delay(60)
123
+ ok(view.stats().frames == 4 && view.stats().drawn == 2, 'busy-skip: overlapping frame dropped, not queued')
124
+ ok(canvas.width == 1280 && canvas.height == 720, 'canvas resized to the new resolution')
125
+
126
+ view.off()
127
+ emit(videoFrame(5, 320, 240), Date.now())
128
+ await delay(10)
129
+ ok(view.stats().frames == 4, 'off() unsubscribes the line')
130
+ }
131
+
132
+ console.log('\n[media-view] publish pipe: stamp + onError')
133
+ {
134
+ const [emit, line] = listen<[Uint8Array]>()
135
+ const sent: {frame: Uint8Array, sentAt?: number}[] = []
136
+ const errors: unknown[] = []
137
+ const off = pipeMediaPublish(line as any, (frame, sentAt) => {
138
+ sent.push({frame, sentAt})
139
+ return frame.byteLength == 0 ? Promise.reject(new Error('boom')) : Promise.resolve(true)
140
+ }, {onError: e => errors.push(e)})
141
+
142
+ emit(pcmFrame(1))
143
+ ok(sent.length == 1 && typeof sent[0].sentAt == 'number', 'frames are published with a wall-clock stamp by default')
144
+
145
+ emit(new Uint8Array(0))
146
+ await delay(10)
147
+ ok(errors.length == 1, 'publish rejection lands in onError, capture loop survives')
148
+
149
+ off()
150
+ emit(pcmFrame(2))
151
+ ok(sent.length == 2, 'off() stops publishing')
152
+
153
+ const noStamp: (number | undefined)[] = []
154
+ const [emit2, line2] = listen<[Uint8Array]>()
155
+ pipeMediaPublish(line2 as any, (_f, sentAt) => { noStamp.push(sentAt) }, {stamp: false})
156
+ emit2(pcmFrame(3))
157
+ ok(noStamp.length == 1 && noStamp[0] == undefined, 'stamp:false publishes without a timestamp')
158
+ }
159
+
160
+ console.log(fails ? `\n${fails} FAILED` : '\nall passed')
161
+ if (fails) process.exit(1)
162
+ }
163
+
164
+ main().catch(e => { console.error(e); process.exit(1) })