wenay-common2 1.0.75 → 1.0.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/doc/ROADMAP.md +7 -6
- package/doc/changes/1.0.75.md +1 -1
- package/doc/changes/1.0.76.md +4 -0
- package/doc/wenay-common2-rare.md +5 -3
- package/doc/wenay-common2.md +5 -2
- package/lib/Common/events/replay-channel.js +55 -6
- package/observe/store-manager.test.ts +43 -1
- package/package.json +1 -1
- package/replay/route-webrtc.test.ts +15 -2
- package/doc/changes/1.0.66.md +0 -8
package/doc/ROADMAP.md
CHANGED
|
@@ -139,12 +139,13 @@ control channel; `authorize` = server-side `canExposeEndpoint`), `createWebRtcCo
|
|
|
139
139
|
`acceptWebRtcDirect` (RTCPeerConnection injected as a runtime factory, structural types, no lib.dom),
|
|
140
140
|
and `serveReplayChannel`/`channelReplayRemote` (replay wire over any ordered channel — the datachannel
|
|
141
141
|
path bypasses the RPC core by design). Oracle `replay/route-webrtc.test.ts` drives promotion, endpoint
|
|
142
|
-
denial, session rejection,
|
|
143
|
-
|
|
144
|
-
the
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for true 30fps (`grabFrame`'s ~50ms serial latency caps snapshot
|
|
142
|
+
denial, session rejection, server revoke, and an encoded `Media` `Uint8Array` frame over both the
|
|
143
|
+
direct datachannel and relay fallback. v1.0.76 completes step 10 with a portable binary replay codec
|
|
144
|
+
and the browser/Node `rtc` factory seam; it also completes step 7 with the app-level
|
|
145
|
+
`noStrict(accountMap)` + `createStoreManager` selected-mirror lifecycle oracle. Media-side candidates
|
|
146
|
+
remain a real `transport:'webrtc'` native media track/SFU for call-grade smoothness and
|
|
147
|
+
`MediaStreamTrackProcessor` capture for true 30fps (`grabFrame`'s ~50ms serial latency caps snapshot
|
|
148
|
+
capture at ~15-20fps); they are performance adapters, not route-coordinator prerequisites.
|
|
148
149
|
|
|
149
150
|
Consumption-layer candidates (2026-07-10, author's ask):
|
|
150
151
|
- ✅ **Call system** — SHIPPED in v1.0.74: `Peer.createCallManager` (ring/accept/decline/hangup as
|
package/doc/changes/1.0.75.md
CHANGED
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
- Replay subscribers on RPC remotes recover automatically from their own last delivered `seq`: live is restored first, racing envelopes queue, then frame/since catch-up is ordered and deduplicated. Queue policy keeps an ungated live line; producer-declared mini-frames/keyframes may cover a raw seq jump without a logical data gap.
|
|
8
8
|
- Sacred queues remain exact: a retained journal replays every missing envelope, while eviction/no-keyframe is a terminal `onError`; queued events after the hole are never emitted as a false continuation.
|
|
9
9
|
- Hard boundaries stay hard: `close()` / `dispose()` and hub `connect()` / `setToken()` terminate the old generation. Pending or attempted ordinary RPC/pipe calls fail on disconnect and are never retried, avoiding duplicated side effects.
|
|
10
|
-
- Add real Socket.IO reconnect coverage for stable proxy identity, deduped multi-consumer recovery, repeated reconnect generations, sacred eviction, offline dispose, token rotation, and multicast lifecycle listeners.
|
|
10
|
+
- Add real Socket.IO reconnect coverage for stable proxy identity, deduped multi-consumer recovery, repeated reconnect generations, sacred eviction, offline dispose, token rotation, and multicast lifecycle listeners.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# 1.0.76
|
|
2
|
+
|
|
3
|
+
- Complete the route-coordinator account lifecycle target: `noStrict` runtime account maps compose with `Observe.createStoreManager` to start/stop selected replay mirrors independently; add the focused oracle.
|
|
4
|
+
- Preserve `Uint8Array` payloads in replay-over-datachannel with a portable binary codec, so encoded `Media` frames survive relay → direct WebRTC → relay hand-offs byte-for-byte; extend the real signaling oracle.
|
|
@@ -551,9 +551,11 @@ createSignalHub({authorize?}) -> {register(account) -> {send, signals, close}, r
|
|
|
551
551
|
// one server-side policy point for endpoint material AND calls.
|
|
552
552
|
createWebRtcConnector({port, rtc, self, peer, pair, session?, label?, openTimeoutMs?}) -> RouteConnector
|
|
553
553
|
// the direct connector for createRouteCoordinator: open() drives offer/answer/ICE through the signal
|
|
554
|
-
// port, waits for the datachannel, returns a replay wire over it. RTCPeerConnection is NOT bundled:
|
|
555
|
-
// rtc is a runtime factory — browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel,
|
|
556
|
-
// tests an in-proc fake (RtcPeerConnection/RtcDataChannel are structural types, no lib.dom).
|
|
554
|
+
// port, waits for the datachannel, returns a replay wire over it. RTCPeerConnection is NOT bundled:
|
|
555
|
+
// rtc is a runtime factory — browser `() => new RTCPeerConnection(cfg)`, Node werift/node-datachannel,
|
|
556
|
+
// tests an in-proc fake (RtcPeerConnection/RtcDataChannel are structural types, no lib.dom).
|
|
557
|
+
// The replay channel encodes Uint8Array explicitly, preserving Media frames byte-for-byte instead
|
|
558
|
+
// of JSON's numeric-key object conversion; native tracks/SFU are optional performance adapters.
|
|
557
559
|
// revoke/close signals and channel death (incl. DURING open) fail loudly -> coordinator auto-fallback.
|
|
558
560
|
acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
|
|
559
561
|
// responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
|
package/doc/wenay-common2.md
CHANGED
|
@@ -195,8 +195,11 @@ me.onRoute(ev => {}) // route transitions for metrics/UI
|
|
|
195
195
|
```
|
|
196
196
|
Key property: the relay journal stores the owner's envelopes VERBATIM (owner seq space), so a
|
|
197
197
|
relay <-> direct hand-off is a plain seq resume — no uncovered loss or duplicate delivery. Late joiners
|
|
198
|
-
get a keyframe folded server-side even while the owner is offline.
|
|
199
|
-
|
|
198
|
+
get a keyframe folded server-side even while the owner is offline.
|
|
199
|
+
The same replay datachannel preserves `Media` `Uint8Array` frames byte-for-byte, so a direct route
|
|
200
|
+
can feed existing `Media` Listen/replay consumers; native WebRTC tracks/SFU are optional future
|
|
201
|
+
performance adapters, not a second media semantic.
|
|
202
|
+
Reconnect correctness is self-healing: a publisher gap makes the relay reject the push WITH its last
|
|
200
203
|
seq, and the client repairs from that coordinate automatically (`repair: 'tail'` lossless (default)
|
|
201
204
|
| `'keyframe'` cheap reset for ephemeral state). Server declares journal semantics
|
|
202
205
|
(`createPeerHost({gap: 'resume' | 'sacred'})`); a declared `journal: 'sacred'` TYPE-forbids the cheap
|
|
@@ -2,6 +2,55 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.serveReplayChannel = serveReplayChannel;
|
|
4
4
|
exports.channelReplayRemote = channelReplayRemote;
|
|
5
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
6
|
+
const REPLAY_BYTES = '__wenayReplayBytes';
|
|
7
|
+
function bytesToBase64(bytes) {
|
|
8
|
+
let out = '';
|
|
9
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
10
|
+
const a = bytes[i];
|
|
11
|
+
const b = bytes[i + 1];
|
|
12
|
+
const c = bytes[i + 2];
|
|
13
|
+
out += BASE64[a >> 2];
|
|
14
|
+
out += BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)];
|
|
15
|
+
out += b == null ? '=' : BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)];
|
|
16
|
+
out += c == null ? '=' : BASE64[c & 63];
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
function base64ToBytes(text) {
|
|
21
|
+
const clean = text.replace(/=+$/, '');
|
|
22
|
+
const out = new Uint8Array(Math.floor(clean.length * 3 / 4));
|
|
23
|
+
let bits = 0;
|
|
24
|
+
let nBits = 0;
|
|
25
|
+
let at = 0;
|
|
26
|
+
for (const char of clean) {
|
|
27
|
+
const n = BASE64.indexOf(char);
|
|
28
|
+
if (n < 0)
|
|
29
|
+
throw new Error('replay channel: invalid binary payload');
|
|
30
|
+
bits = (bits << 6) | n;
|
|
31
|
+
nBits += 6;
|
|
32
|
+
if (nBits < 8)
|
|
33
|
+
continue;
|
|
34
|
+
nBits -= 8;
|
|
35
|
+
out[at++] = (bits >> nBits) & 255;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function stringifyMessage(value) {
|
|
40
|
+
return JSON.stringify(value, function encodeReplayBytes(_key, item) {
|
|
41
|
+
if (item instanceof Uint8Array)
|
|
42
|
+
return { [REPLAY_BYTES]: bytesToBase64(item) };
|
|
43
|
+
return item;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function parseMessage(raw) {
|
|
47
|
+
return JSON.parse(raw, function decodeReplayBytes(_key, item) {
|
|
48
|
+
if (item != null && typeof item == 'object' && Object.keys(item).length == 1 && typeof item[REPLAY_BYTES] == 'string') {
|
|
49
|
+
return base64ToBytes(item[REPLAY_BYTES]);
|
|
50
|
+
}
|
|
51
|
+
return item;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
5
54
|
function unsubscribeHandle(handle) {
|
|
6
55
|
if (typeof handle == 'function') {
|
|
7
56
|
handle();
|
|
@@ -22,11 +71,11 @@ function serveReplayChannel(source, channel) {
|
|
|
22
71
|
: msg.m == 'frame' ? (source.frame ? await source.frame(msg.a[0], msg.a[1]) : null)
|
|
23
72
|
: undefined;
|
|
24
73
|
if (!closed)
|
|
25
|
-
channel.send(
|
|
74
|
+
channel.send(stringifyMessage({ t: 'res', id: msg.id, ok: true, v: v ?? null }));
|
|
26
75
|
}
|
|
27
76
|
catch (e) {
|
|
28
77
|
if (!closed)
|
|
29
|
-
channel.send(
|
|
78
|
+
channel.send(stringifyMessage({ t: 'res', id: msg.id, ok: false, e: String(e) }));
|
|
30
79
|
}
|
|
31
80
|
}
|
|
32
81
|
const offMsg = channel.onMessage(function onReplayChannelMessage(raw) {
|
|
@@ -34,7 +83,7 @@ function serveReplayChannel(source, channel) {
|
|
|
34
83
|
return;
|
|
35
84
|
let msg;
|
|
36
85
|
try {
|
|
37
|
-
msg =
|
|
86
|
+
msg = parseMessage(raw);
|
|
38
87
|
}
|
|
39
88
|
catch {
|
|
40
89
|
return;
|
|
@@ -42,7 +91,7 @@ function serveReplayChannel(source, channel) {
|
|
|
42
91
|
if (msg?.t == 'sub' && !lineOff) {
|
|
43
92
|
lineOff = source.line.on(function forwardEnvelope(ev) {
|
|
44
93
|
if (!closed)
|
|
45
|
-
channel.send(
|
|
94
|
+
channel.send(stringifyMessage({ t: 'ev', ev }));
|
|
46
95
|
});
|
|
47
96
|
return;
|
|
48
97
|
}
|
|
@@ -70,7 +119,7 @@ function channelReplayRemote(channel) {
|
|
|
70
119
|
channel.onMessage(function onRemoteChannelMessage(raw) {
|
|
71
120
|
let msg;
|
|
72
121
|
try {
|
|
73
|
-
msg =
|
|
122
|
+
msg = parseMessage(raw);
|
|
74
123
|
}
|
|
75
124
|
catch {
|
|
76
125
|
return;
|
|
@@ -108,7 +157,7 @@ function channelReplayRemote(channel) {
|
|
|
108
157
|
return new Promise((resolve, reject) => {
|
|
109
158
|
const id = nextId++;
|
|
110
159
|
pending.set(id, { resolve, reject });
|
|
111
|
-
channel.send(
|
|
160
|
+
channel.send(stringifyMessage({ t: 'req', id, m, a }));
|
|
112
161
|
});
|
|
113
162
|
}
|
|
114
163
|
return {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
flushReactive,
|
|
8
8
|
managedStore,
|
|
9
9
|
} from '../src/Common/Observe'
|
|
10
|
+
import {isNoStrict, noStrict} from '../src/Common/rcp/rpc-dynamic'
|
|
10
11
|
|
|
11
12
|
let fails = 0
|
|
12
13
|
const ok = (condition: any, message: string) => {
|
|
@@ -111,8 +112,49 @@ async function main() {
|
|
|
111
112
|
ok(manager.handles.rows.status().state == 'stopped', 'stopAll closes offline resource')
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
console.log('\n[store-manager] dynamic account-map lifecycle')
|
|
116
|
+
{
|
|
117
|
+
const sources = new Map<string, ReturnType<typeof createStore<Market>>>()
|
|
118
|
+
const remotes = noStrict(new Proxy({} as Record<string, any>, {
|
|
119
|
+
get(_target, key) {
|
|
120
|
+
if (typeof key != 'string') return undefined
|
|
121
|
+
let source = sources.get(key)
|
|
122
|
+
if (!source) {
|
|
123
|
+
source = createStore<Market>({data: {BTC: key.length}, meta: {status: 'relay'}}, {drain: 'micro'})
|
|
124
|
+
sources.set(key, source)
|
|
125
|
+
}
|
|
126
|
+
return exposeStoreReplay(source, {history: 20}).api.replay
|
|
127
|
+
},
|
|
128
|
+
}))
|
|
129
|
+
function accountResource(account: string) {
|
|
130
|
+
return managedStore.replay<Market>({
|
|
131
|
+
remote: remotes[account],
|
|
132
|
+
initial: {data: {}, meta: {}},
|
|
133
|
+
storeOpts: {drain: 'micro'},
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
const manager = createStoreManager({
|
|
137
|
+
alice: accountResource('alice'),
|
|
138
|
+
bob: accountResource('bob'),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
ok(isNoStrict(remotes), 'runtime account map is explicitly noStrict, not a fixed schema')
|
|
142
|
+
const alice = await manager.start('alice')
|
|
143
|
+
ok(alice.state.data.BTC == 5 && manager.get('bob') == null, 'start selects one account mirror without materializing its peers')
|
|
144
|
+
|
|
145
|
+
const source = sources.get('alice')!
|
|
146
|
+
source.state.meta.status = 'direct'
|
|
147
|
+
await settle(source.state)
|
|
148
|
+
ok(alice.state.meta.status == 'direct', 'selected account mirror follows its replay route')
|
|
149
|
+
|
|
150
|
+
manager.stop('alice')
|
|
151
|
+
ok(manager.handles.alice.status().state == 'stopped' && manager.get('alice') != null,
|
|
152
|
+
'stopping a selected account detaches its route while retaining its local mirror for reuse')
|
|
153
|
+
manager.stopAll()
|
|
154
|
+
}
|
|
155
|
+
|
|
114
156
|
console.log(`\n${fails == 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
115
157
|
process.exit(fails == 0 ? 0 : 1)
|
|
116
158
|
}
|
|
117
159
|
|
|
118
|
-
main().catch(e => { console.error(e); process.exit(1) })
|
|
160
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '../src/Common/events/replay-index'
|
|
17
17
|
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
18
18
|
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
19
|
+
import {decodeMediaFrame, encodeMediaFrame} from '../src/Common/media/media-source'
|
|
19
20
|
|
|
20
21
|
let fails = 0
|
|
21
22
|
const ok = (condition: any, message: string) => {
|
|
@@ -199,12 +200,24 @@ async function main() {
|
|
|
199
200
|
await waitFor('datachannel live events', () => got.length >= 4)
|
|
200
201
|
ok(json(got) == json([0, 1, 2, 3]), 'replay wire over the datachannel is gap-free and dup-free')
|
|
201
202
|
|
|
203
|
+
// A direct media route uses the same replay wire. Binary frames must stay
|
|
204
|
+
// Uint8Array end-to-end — JSON's default numeric-key object is unusable by Media.
|
|
205
|
+
const mediaFrame = encodeMediaFrame({
|
|
206
|
+
kind: 'video-frame', codec: 'jpeg', seq: 4, tMono: 40, width: 2, height: 2,
|
|
207
|
+
}, new Uint8Array([77, 101, 100, 105, 97]))
|
|
208
|
+
emit(mediaFrame as any)
|
|
209
|
+
await waitFor('binary frame over datachannel', () => got.length >= 5)
|
|
210
|
+
const deliveredFrame = got[4] as any
|
|
211
|
+
const decodedFrame = deliveredFrame instanceof Uint8Array ? decodeMediaFrame(deliveredFrame) : null
|
|
212
|
+
ok(decodedFrame?.kind == 'video-frame' && json([...decodedFrame.payload]) == json([77, 101, 100, 105, 97]),
|
|
213
|
+
'direct replay preserves an encoded Media Uint8Array frame byte-for-byte')
|
|
214
|
+
|
|
202
215
|
// server-side revoke: policy изменилась — direct закрывается, relay продолжает с seq
|
|
203
216
|
hub.revoke(link.ref.key, ['a', 'b'], 'policy change')
|
|
204
217
|
await waitFor('revoke fallback', () => link.state() == 'fallback')
|
|
205
218
|
state = 4; emit(state)
|
|
206
219
|
await waitFor('relay after revoke', () => got.includes(4))
|
|
207
|
-
ok(json(got) == json([0, 1, 2, 3, 4]), 'server revoke: relay resumes from seq, no gap')
|
|
220
|
+
ok(json(got.filter(v => typeof v == 'number')) == json([0, 1, 2, 3, 4]), 'server revoke: relay resumes from seq, no gap')
|
|
208
221
|
|
|
209
222
|
// сервер не раскрывает endpoint: offer отклонён ДО транспорта
|
|
210
223
|
allowDirect = false
|
|
@@ -223,7 +236,7 @@ async function main() {
|
|
|
223
236
|
const retry = await link.promoteDirect()
|
|
224
237
|
state = 5; emit(state)
|
|
225
238
|
await waitFor('direct again', () => got.includes(5))
|
|
226
|
-
ok(retry.ok && link.state() == 'direct' && json(got) == json([0, 1, 2, 3, 4, 5]), 'pair recovers into direct after denials, still gap-free')
|
|
239
|
+
ok(retry.ok && link.state() == 'direct' && json(got.filter(v => typeof v == 'number')) == json([0, 1, 2, 3, 4, 5]), 'pair recovers into direct after denials, still gap-free')
|
|
227
240
|
|
|
228
241
|
coord.close()
|
|
229
242
|
stopAccept()
|
package/doc/changes/1.0.66.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# 1.0.66
|
|
2
|
-
|
|
3
|
-
- Add `Media.createAudioSource` and `Media.createVideoSource` browser capture helpers that expose media as ordinary binary `Listen` sources.
|
|
4
|
-
- Add fixed-header media frame helpers: `Media.encodeMediaFrame` and `Media.decodeMediaFrame` (`Uint8Array` header + raw payload, no JSON envelope).
|
|
5
|
-
- Add optional `replay:true` media source mode so `createRpcServerAuto` can expose media listeners with legacy + replay surfaces under the same key.
|
|
6
|
-
- Add `wenay-common2/media` package export and root `Media` namespace export.
|
|
7
|
-
- Add `replay/media-socket.test.ts` covering media binary frame headers, no-device typed state, replay media source shape, and real Socket.IO/RPC binary delivery.
|
|
8
|
-
- Document media-over-socket backpressure defaults and reserve WebRTC as a future explicit SFU/signaling opt-in, not the default transport.
|