wenay-common2 1.0.70 → 1.0.73
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/README.md +2 -1
- package/demo/client.ts +123 -1
- package/demo/index.html +30 -0
- package/demo/server.ts +52 -2
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +19 -1
- package/doc/changes/1.0.71.md +9 -0
- package/doc/changes/1.0.72.md +6 -0
- package/doc/changes/1.0.73.md +6 -0
- package/doc/wenay-common2-rare.md +29 -8
- package/doc/wenay-common2.md +13 -2
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +1 -0
- package/lib/Common/media/media-source.d.ts +3 -0
- package/lib/Common/media/media-source.js +136 -27
- package/lib/Common/media/media-view.d.ts +42 -0
- package/lib/Common/media/media-view.js +191 -0
- package/lib/Common/peer/peer-client.d.ts +12 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +8 -4
- package/lib/Common/peer/peer-host.js +2 -2
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/package.json +1 -1
- package/replay/media-view.test.ts +164 -0
- package/replay/peer-repair.test.ts +184 -0
- package/doc/changes/1.0.63.md +0 -8
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
## Living examples (shipped in the npm package)
|
|
14
14
|
|
|
15
15
|
- [`demo/`](demo/) — runnable stand (`npm run demo`): shared cursors in two browser tabs over the
|
|
16
|
-
Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key
|
|
16
|
+
Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key; plus live media — camera,
|
|
17
|
+
microphone and screen share captured with the `Media` sources and streamed to the watching tab.
|
|
17
18
|
- [`replay/`](replay/) · [`observe/`](observe/) · [`oracle/`](oracle/) — the oracle suites CI runs on
|
|
18
19
|
every push; each file doubles as a worked usage example of one subsystem.
|
|
19
20
|
- Note: examples import from the repo's `src/`; in the installed package the same API comes from
|
package/demo/client.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Demo stand client: shared cursors over the Peer SDK — relay by default,
|
|
2
2
|
// "Go direct" promotes to a real RTCPeerConnection datachannel, "Back to relay"
|
|
3
3
|
// re-interposes. The route hand-off is gap-free by seq; the cursor never jumps.
|
|
4
|
+
// Plus live media: camera / mic / screen share captured with the Media sources and
|
|
5
|
+
// streamed to the watching tab through the demo server's media relay.
|
|
4
6
|
import {io} from 'socket.io-client'
|
|
5
7
|
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
6
8
|
import {createPeerClient} from '../src/Common/peer/peer-index'
|
|
9
|
+
import {attachAudioPlayer, attachVideoCanvas, createAudioSource, createVideoSource, pipeMediaPublish} from '../src/Common/media/media-index'
|
|
7
10
|
|
|
8
11
|
type World = {cursor: {x: number, y: number}, color: string, name: string}
|
|
9
12
|
|
|
@@ -27,7 +30,7 @@ async function main() {
|
|
|
27
30
|
el('who').textContent = `me: ${me} · watching: ${other}`
|
|
28
31
|
|
|
29
32
|
const hub = createRpcClientHub(
|
|
30
|
-
() => io({transports: ['websocket'], auth: {account: me}}),
|
|
33
|
+
() => io({transports: ['websocket'], auth: {account: me, watch: other}}),
|
|
31
34
|
r => ({app: r<any>('app')}) as const,
|
|
32
35
|
)
|
|
33
36
|
const clients = await hub.setToken(null)
|
|
@@ -95,6 +98,125 @@ async function main() {
|
|
|
95
98
|
const res = await peer.reinterposeRelay('manual')
|
|
96
99
|
log(res.ok ? 'back on relay' : `re-interpose failed: ${String(res.reason)}`)
|
|
97
100
|
})
|
|
101
|
+
|
|
102
|
+
setupMedia(clients.app.func.media)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============== media: capture own cam/mic/screen, watch the peer's ==============
|
|
106
|
+
type tMediaKind = 'cam' | 'mic' | 'screen'
|
|
107
|
+
|
|
108
|
+
function setupMedia(media: any) {
|
|
109
|
+
// -------- publish own frames through the demo relay (fire-and-forget) --------
|
|
110
|
+
function pipePublish(kind: tMediaKind, src: any) {
|
|
111
|
+
pipeMediaPublish(src[1], (frame, sentAt) => media.publish(kind, frame, sentAt), {
|
|
112
|
+
onError: e => log(`media publish ${kind} failed: ${e}`),
|
|
113
|
+
})
|
|
114
|
+
return src
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const camResEl = el('camRes') as HTMLSelectElement
|
|
118
|
+
function makeCam() {
|
|
119
|
+
// library fps default (3) targets machine vision; a live stand wants motion
|
|
120
|
+
return pipePublish('cam', createVideoSource({sourceId: 'cam', fps: 60, width: Number(camResEl.value) || 640, codec: 'jpeg'}))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sources = {
|
|
124
|
+
cam: makeCam(),
|
|
125
|
+
screen: pipePublish('screen', createVideoSource({
|
|
126
|
+
sourceId: 'screen',
|
|
127
|
+
fps: 10,
|
|
128
|
+
codec: 'jpeg',
|
|
129
|
+
quality: 0.5, // full-screen JPEGs get large fast; favor latency
|
|
130
|
+
// the documented `stream` injection point: skip getUserMedia, bring getDisplayMedia
|
|
131
|
+
stream: () => (navigator.mediaDevices as any).getDisplayMedia({video: true}),
|
|
132
|
+
})),
|
|
133
|
+
// big buffers on purpose: the worklet's 128-sample chunks would be ~375 socket
|
|
134
|
+
// messages per second and drown the shared connection (lag for everything)
|
|
135
|
+
mic: pipePublish('mic', createAudioSource({sourceId: 'mic', worklet: false, bufferSize: 4096})),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// resolution stress test: swap the camera source on the fly, keep it live if it was
|
|
139
|
+
camResEl.addEventListener('change', async function onCamResChange() {
|
|
140
|
+
const wasLive = sources.cam.state == 'live'
|
|
141
|
+
sources.cam.stop()
|
|
142
|
+
sources.cam = makeCam()
|
|
143
|
+
if (wasLive) {
|
|
144
|
+
const state = await sources.cam.start()
|
|
145
|
+
log(`cam @${camResEl.value}p: ${state}`)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// -------- capture toggles --------
|
|
150
|
+
function bindToggle(id: string, kind: tMediaKind, label: string) {
|
|
151
|
+
const btn = el(id) as HTMLButtonElement
|
|
152
|
+
btn.addEventListener('click', async function toggleCapture() {
|
|
153
|
+
const src = sources[kind]
|
|
154
|
+
if (src.state == 'live') {
|
|
155
|
+
src.stop()
|
|
156
|
+
btn.textContent = label
|
|
157
|
+
log(`${kind}: stopped`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
btn.textContent = `${label} …`
|
|
161
|
+
const state = await src.start()
|
|
162
|
+
btn.textContent = state == 'live' ? `${label} ⏹` : label
|
|
163
|
+
log(`${kind}: ${state}${state != 'live' && src.getStats().error ? ' — ' + src.getStats().error : ''}`)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
bindToggle('cam', 'cam', '📷 camera')
|
|
167
|
+
bindToggle('mic', 'mic', '🎙 mic')
|
|
168
|
+
bindToggle('screen', 'screen', '🖥 screen')
|
|
169
|
+
|
|
170
|
+
// -------- watch the peer's lines (one call per channel) --------
|
|
171
|
+
function watchVideo(line: any, canvasId: string, caption: string) {
|
|
172
|
+
const canvas = el(canvasId) as HTMLCanvasElement
|
|
173
|
+
const captionEl = el(canvasId + 'Cap')
|
|
174
|
+
canvas.addEventListener('click', function goFullscreen() {
|
|
175
|
+
void (document.fullscreenElement == canvas ? document.exitFullscreen() : canvas.requestFullscreen?.())
|
|
176
|
+
})
|
|
177
|
+
const view = attachVideoCanvas(line, canvas, {onError: e => log('video frame render failed: ' + e)})
|
|
178
|
+
return {view, captionEl, caption}
|
|
179
|
+
}
|
|
180
|
+
const peerCam = watchVideo(media.peer.cam, 'peerCam', 'peer camera')
|
|
181
|
+
const peerScreen = watchVideo(media.peer.screen, 'peerScreen', 'peer screen')
|
|
182
|
+
|
|
183
|
+
const player = attachAudioPlayer(media.peer.mic, {onError: e => log('audio frame failed: ' + e)})
|
|
184
|
+
const audioBtn = el('audio') as HTMLButtonElement
|
|
185
|
+
audioBtn.addEventListener('click', function togglePeerAudio() {
|
|
186
|
+
if (player.enabled) {
|
|
187
|
+
player.disable()
|
|
188
|
+
audioBtn.textContent = '🔊 peer audio'
|
|
189
|
+
} else {
|
|
190
|
+
player.enable()
|
|
191
|
+
audioBtn.textContent = '🔊 peer audio ⏹'
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// -------- stats line: own capture + helper-provided rx metrics --------
|
|
196
|
+
const statsEl = el('mediaStats')
|
|
197
|
+
let prevTx = {cam: 0, mic: 0, screen: 0}
|
|
198
|
+
setInterval(function renderMediaStats() {
|
|
199
|
+
const parts: string[] = []
|
|
200
|
+
const nextTx = {cam: 0, mic: 0, screen: 0}
|
|
201
|
+
for (const kind of ['cam', 'mic', 'screen'] as const) {
|
|
202
|
+
const s = sources[kind].getStats()
|
|
203
|
+
nextTx[kind] = s.frames
|
|
204
|
+
if (s.state != 'idle') parts.push(`${kind}: ${s.state} ${s.frames}f ${s.frames - prevTx[kind]}/s${s.rms != null ? ` rms=${s.rms.toFixed(3)}` : ''}`)
|
|
205
|
+
}
|
|
206
|
+
prevTx = nextTx
|
|
207
|
+
const cam = peerCam.view.stats()
|
|
208
|
+
const screen = peerScreen.view.stats()
|
|
209
|
+
const mic = player.stats()
|
|
210
|
+
for (const v of [peerCam, peerScreen]) {
|
|
211
|
+
const s = v.view.stats()
|
|
212
|
+
if (s.width) v.captionEl.textContent = `${v.caption} · ${s.width}×${s.height} · click = fullscreen`
|
|
213
|
+
}
|
|
214
|
+
if (cam.frames || screen.frames || mic.frames) parts.push(
|
|
215
|
+
`rx: cam ${cam.frames}f/${cam.drawn}d ${cam.perSec}/s ~${cam.ageMs}ms` +
|
|
216
|
+
` · screen ${screen.frames}f/${screen.drawn}d ${screen.perSec}/s ~${screen.ageMs}ms` +
|
|
217
|
+
` · mic ${mic.frames}f ${mic.perSec}/s ~${mic.ageMs}ms`)
|
|
218
|
+
statsEl.textContent = parts.join(' · ')
|
|
219
|
+
}, 1000)
|
|
98
220
|
}
|
|
99
221
|
|
|
100
222
|
main().catch(e => { console.error(e); log('FATAL: ' + e) })
|
package/demo/index.html
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
button { padding: 6px 14px; border-radius: 6px; border: 1px solid #888; background: #fff; cursor: pointer; }
|
|
12
12
|
button:hover { background: #f0f0f0; }
|
|
13
13
|
#log { font-family: monospace; font-size: 12px; margin-top: 8px; max-height: 180px; overflow-y: auto; color: #555; }
|
|
14
|
+
#mediaView { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
|
|
15
|
+
#mediaView figure { margin: 0; }
|
|
16
|
+
#mediaView figcaption { font-size: 12px; color: #777; margin-bottom: 4px; }
|
|
17
|
+
#peerCam, #peerScreen { border: 1px solid #bbb; border-radius: 6px; background: #111; max-width: 480px; height: auto; cursor: zoom-in; }
|
|
18
|
+
#peerCam:fullscreen, #peerScreen:fullscreen { width: 100vw; height: 100vh; object-fit: contain; background: #000; border: none; }
|
|
19
|
+
select { padding: 6px 8px; border-radius: 6px; border: 1px solid #888; background: #fff; }
|
|
20
|
+
#mediaStats { font-family: monospace; font-size: 12px; color: #777; }
|
|
14
21
|
</style>
|
|
15
22
|
</head>
|
|
16
23
|
<body>
|
|
@@ -22,6 +29,29 @@
|
|
|
22
29
|
<span id="route"></span>
|
|
23
30
|
</div>
|
|
24
31
|
<canvas id="canvas" width="640" height="360"></canvas>
|
|
32
|
+
<div id="bar">
|
|
33
|
+
<button id="cam">📷 camera</button>
|
|
34
|
+
<select id="camRes" title="camera capture width">
|
|
35
|
+
<option value="320">320p</option>
|
|
36
|
+
<option value="640" selected>640p</option>
|
|
37
|
+
<option value="1280">1280p</option>
|
|
38
|
+
<option value="1920">1920p</option>
|
|
39
|
+
</select>
|
|
40
|
+
<button id="mic">🎙 mic</button>
|
|
41
|
+
<button id="screen">🖥 screen</button>
|
|
42
|
+
<button id="audio">🔊 peer audio</button>
|
|
43
|
+
<span id="mediaStats"></span>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="mediaView">
|
|
46
|
+
<figure>
|
|
47
|
+
<figcaption id="peerCamCap">peer camera · click = fullscreen</figcaption>
|
|
48
|
+
<canvas id="peerCam" width="320" height="240"></canvas>
|
|
49
|
+
</figure>
|
|
50
|
+
<figure>
|
|
51
|
+
<figcaption id="peerScreenCap">peer screen · click = fullscreen</figcaption>
|
|
52
|
+
<canvas id="peerScreen" width="480" height="270"></canvas>
|
|
53
|
+
</figure>
|
|
54
|
+
</div>
|
|
25
55
|
<div id="log"></div>
|
|
26
56
|
<script src="client.js"></script>
|
|
27
57
|
</body>
|
package/demo/server.ts
CHANGED
|
@@ -5,21 +5,66 @@ import {createServer} from 'http'
|
|
|
5
5
|
import path from 'path'
|
|
6
6
|
import {Server as SocketIOServer} from 'socket.io'
|
|
7
7
|
import {listen} from '../src/Common/events/Listen'
|
|
8
|
+
import {replayListen} from '../src/Common/events/replay-listen'
|
|
8
9
|
import {createPeerHost} from '../src/Common/peer/peer-index'
|
|
9
10
|
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
10
11
|
|
|
11
12
|
const PORT = Number(process.env.PORT ?? 8390)
|
|
12
13
|
|
|
14
|
+
// ============== demo media hub: per-account replay lines ==============
|
|
15
|
+
// Browser capture (cam/mic/screen) originates on the CLIENT, so the server side is a
|
|
16
|
+
// tiny relay: publish() feeds the publisher's line, watchers get plain Listen surfaces
|
|
17
|
+
// exposed through createRpcServerAuto like any other Listen. Video lines are keep-latest
|
|
18
|
+
// (a late joiner instantly gets the last frame), audio is a short lossless queue.
|
|
19
|
+
type tMediaKind = 'cam' | 'mic' | 'screen'
|
|
20
|
+
|
|
21
|
+
function createDemoMediaHub() {
|
|
22
|
+
// [frame, sentAt]: wall-clock publish time rides along so viewers can show real latency
|
|
23
|
+
function createLines() {
|
|
24
|
+
return {
|
|
25
|
+
cam: replayListen<[Uint8Array, number]>({history: 8, current: 'last', frame: tail => tail.length ? [tail[tail.length - 1]] : []}),
|
|
26
|
+
screen: replayListen<[Uint8Array, number]>({history: 8, current: 'last', frame: tail => tail.length ? [tail[tail.length - 1]] : []}),
|
|
27
|
+
mic: replayListen<[Uint8Array, number]>({history: 64}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const accounts = new Map<string, ReturnType<typeof createLines>>()
|
|
31
|
+
function linesOf(account: string) {
|
|
32
|
+
let lines = accounts.get(account)
|
|
33
|
+
if (!lines) {
|
|
34
|
+
lines = createLines()
|
|
35
|
+
accounts.set(account, lines)
|
|
36
|
+
}
|
|
37
|
+
return lines
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
// what the publishing account calls
|
|
41
|
+
publishOf(account: string) {
|
|
42
|
+
return function publish(kind: tMediaKind, frame: Uint8Array, sentAt: number) {
|
|
43
|
+
const line = linesOf(account)[kind]
|
|
44
|
+
if (line) line[0](frame, sentAt)
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
// what a watcher subscribes to
|
|
48
|
+
watchOf(account: string) {
|
|
49
|
+
const lines = linesOf(account)
|
|
50
|
+
return {cam: lines.cam[1], mic: lines.mic[1], screen: lines.screen[1]}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
13
55
|
const host = createPeerHost()
|
|
56
|
+
const mediaHub = createDemoMediaHub()
|
|
14
57
|
const app = express()
|
|
15
58
|
app.use(express.static(path.resolve(__dirname, 'public')))
|
|
16
59
|
app.get('/', (_req, res) => res.sendFile(path.resolve(__dirname, 'public', 'index.html')))
|
|
17
60
|
|
|
18
61
|
const httpServer = createServer(app)
|
|
19
|
-
|
|
62
|
+
// screen-share JPEG frames can exceed the 1MB Socket.IO default
|
|
63
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
20
64
|
|
|
21
65
|
ioServer.on('connection', function onDemoConnection(socket) {
|
|
22
66
|
const account = String(socket.handshake.auth?.account ?? 'anon')
|
|
67
|
+
const watch = String(socket.handshake.auth?.watch ?? (account == 'a' ? 'b' : 'a'))
|
|
23
68
|
const peer = host.connection(account)
|
|
24
69
|
const [disconnect, disconnectListen] = listen<[]>()
|
|
25
70
|
socket.on('disconnect', () => { disconnect(); peer.close() })
|
|
@@ -30,10 +75,15 @@ ioServer.on('connection', function onDemoConnection(socket) {
|
|
|
30
75
|
// legacy key on the SAME connection — the SDK does not displace old code
|
|
31
76
|
serverTime: () => new Date().toISOString(),
|
|
32
77
|
peer: peer.fragment,
|
|
78
|
+
media: {
|
|
79
|
+
publish: mediaHub.publishOf(account),
|
|
80
|
+
// the watched account's live lines, declared at connect time (auth.watch)
|
|
81
|
+
peer: mediaHub.watchOf(watch),
|
|
82
|
+
},
|
|
33
83
|
},
|
|
34
84
|
disconnectListen,
|
|
35
85
|
})
|
|
36
|
-
console.log(`[demo] ${account} connected`)
|
|
86
|
+
console.log(`[demo] ${account} connected (watching ${watch})`)
|
|
37
87
|
})
|
|
38
88
|
|
|
39
89
|
httpServer.listen(PORT, function onDemoListen() {
|
package/doc/INTENT.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# wenay-common2 — project intent
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
A personal, open-source common/transport library, matured over many years. The published `1.x`
|
|
6
|
+
line is really a **second generation** ("version 1" the number, version 2 the design) — the earlier
|
|
7
|
+
generation and its plan/history docs were deliberately removed; the canonical surface today is
|
|
8
|
+
`doc/wenay-common2.md` (brief) + `doc/wenay-common2-rare.md` (full reference).
|
|
9
|
+
|
|
10
|
+
The current stack is a small distributed-state runtime: a typed RPC core, an `Observe` reactive
|
|
11
|
+
store, a universal replay layer (`seq` + keyframe + deltas), a policy-gated route coordinator
|
|
12
|
+
(relay ⇄ WebRTC direct), a WebRTC signaling adapter, a media-over-socket layer, and a one-call
|
|
13
|
+
`Peer` SDK on top. It is oracle-covered (CI on every push) and shipped with living examples and a
|
|
14
|
+
runnable demo stand in the npm package.
|
|
15
|
+
|
|
16
|
+
## Intent (2026-07)
|
|
17
|
+
|
|
18
|
+
- **Open source, not promoted.** The library is public for use and as a portfolio/reference, but the
|
|
19
|
+
author is **not** going to actively market or grow a community around it. Other side-projects are
|
|
20
|
+
the current focus.
|
|
21
|
+
- **No pressure to "finish" transport.** The transport/routing/replay lower half is built and good
|
|
22
|
+
enough; ROADMAP transport items are 🧊 deferred (super-low priority, not forbidden). They reopen
|
|
23
|
+
only when a real consumer hits a wall — never speculatively.
|
|
24
|
+
- **Value is in being legible and demoable.** The measure of progress is "what can be shown
|
|
25
|
+
working," not layers added. The demo stand and the oracle examples exist to make the design
|
|
26
|
+
readable and hireable, not to chase feature completeness.
|
|
27
|
+
- **Direction if picked up again:** the consumption layer (framework adapters — React hooks first),
|
|
28
|
+
and the showcase (a hero relay→direct demo). Everything else waits for a concrete need.
|
|
29
|
+
|
|
30
|
+
This file is the durable record of that intent. It is not a worklist; the forward backlog (kept for
|
|
31
|
+
reference, not commitment) lives in `doc/ROADMAP.md` and `doc/target/`.
|
package/doc/ROADMAP.md
CHANGED
|
@@ -141,7 +141,25 @@ and `serveReplayChannel`/`channelReplayRemote` (replay wire over any ordered cha
|
|
|
141
141
|
path bypasses the RPC core by design). Oracle `replay/route-webrtc.test.ts` drives promotion, endpoint
|
|
142
142
|
denial, session rejection, and server revoke over both an in-proc hub and a real Socket.IO/RPC wire.
|
|
143
143
|
Still open: browser/Node WebRTC glue (step 10 — now a one-line `rtc` factory plus media re-emit) and
|
|
144
|
-
the account-map lifecycle integration (step 7).
|
|
144
|
+
the account-map lifecycle integration (step 7). Media-side candidates (2026-07-10, after the demo
|
|
145
|
+
stand stress test): a real `transport:'webrtc'` media track — SDP over the existing signal hub,
|
|
146
|
+
media bypassing the socket relay for call-grade smoothness; and `MediaStreamTrackProcessor` capture
|
|
147
|
+
for true 30fps (`grabFrame`'s ~50ms serial latency caps snapshot capture at ~15-20fps).
|
|
148
|
+
|
|
149
|
+
Consumption-layer candidates (2026-07-10, author's ask):
|
|
150
|
+
- **Call system** — one account calls another, messenger-style: presence (the host already knows
|
|
151
|
+
who is connected), ring / accept / decline envelopes over the existing signal hub, then media
|
|
152
|
+
over the socket relay (mainstream messengers route calls through their servers too — relay-first
|
|
153
|
+
is the privacy default, `promoteDirect` stays the opt-in). Builds entirely on shipped parts:
|
|
154
|
+
peer host + signal hub + media lines + `Media.attach*` viewers.
|
|
155
|
+
- **Store-descriptor media bridge** ("streaming center") — the store carries a small descriptor
|
|
156
|
+
(`{kind: 'video', sourceId, state}`), a bridge watches the mirror and auto-attaches/detaches
|
|
157
|
+
`Media.attachVideoCanvas`/`attachAudioPlayer` to the matching binary line. Bytes flow only while
|
|
158
|
+
subscribed (native Listen-over-RPC semantics). Pairs with the React-hooks direction.
|
|
159
|
+
- 🧊 **Zero-parse patch format** (super-far future) — Cap'n Proto / FlatBuffers-style layout for
|
|
160
|
+
store patches: fields read directly from the received buffer, no parse stage. Only if a real
|
|
161
|
+
consumer hits a serialization wall; JSON envelopes are nowhere near the bottleneck today
|
|
162
|
+
(measured 2-3ms e2e on the stand).
|
|
145
163
|
|
|
146
164
|
## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
|
|
147
165
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# 1.0.71
|
|
2
|
+
|
|
3
|
+
- Publish-path reconnect correctness for the Peer SDK: the relay journal never lies after a publisher gap.
|
|
4
|
+
- `Peer.createPatchRelayJournal({gap: 'resume' | 'sacred'})` — the server declares journal semantics: `'resume'` (default) folds a keyframe and takes a ROOT patch as the one legitimate reset point; `'sacred'` never invents (no folded keyframe, no root reset, strict contiguity, `frame()` throws on an evicted tail).
|
|
5
|
+
- `push()` now returns `true | false | {seq}` — a rejected gap carries the relay's last seq, and that coordinate IS the repair request (self-healing protocol, no separate handshake); duplicates are idempotent no-ops; a non-root FIRST envelope is rejected (a partial-state fold would lie to late joiners); rejection never corrupts the fold.
|
|
6
|
+
- `Peer.createPeerClient`: rejection-driven repair — `repair: 'tail'` (missed envelopes verbatim, keyframe fallback on local eviction) or `'keyframe'` (one cheap root reset for ephemeral state); `journal: 'sacred'` narrows `repair` to `'tail'` at the TYPE level (`tPublishRepair<J>`) — incompatible asks are forbidden by typing, not runtime checks, and a lying client just keeps getting rejections, loudly via `onPublishError`.
|
|
7
|
+
- `client.resync()` — call after a transport reconnect: compares the relay's `seq()` (additive on the wire) with the local line and repairs the gap without waiting for the next write.
|
|
8
|
+
- Add oracle `replay/peer-repair.test.ts`: full gap matrix (folding + sacred), both repair modes over a lossy transport, sacred local-eviction fault surfacing, `resync()`.
|
|
9
|
+
- Media sources: `replay` now also accepts custom `ReplayListenUseOptions` (`replay: {history, current, frame}`), not just `true`; defaults stay per-kind (audio — sacred queue `history:1024`, video — keep-latest `history:256`).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 1.0.72
|
|
2
|
+
|
|
3
|
+
- Media video sources survive hidden/occluded tabs (Chrome throttling). Three measured stalls, three escape hatches, all on by default (`worker: false` opts out): capture tick moves from `setInterval` (~1/s hidden) to a Blob-worker timer; frames come from `ImageCapture.grabFrame()` (a hidden `<video>` stops painting); JPEG encode moves into a worker with a transferred `ImageBitmap` (main-thread `convertToBlob` is gated to ~1000ms hidden, ~5-10ms in the worker). Measured result: a hidden tab streams a stable ~16fps instead of decaying to 1fps after ~30s.
|
|
4
|
+
- Demo stand: live media — camera, microphone and screen share, captured with the public `Media` sources and streamed between the two tabs. Screen share uses the documented `stream` injection point (`createVideoSource({stream: () => getDisplayMedia(...)})`); the demo server grows a tiny relay of per-account replay lines next to `peer.fragment` (video keep-latest, audio short queue, watched account declared via `auth.watch`, frames ride with a `sentAt` wall-clock stamp).
|
|
5
|
+
- Demo client: capture toggles + per-second tx/rx rates and publish->receive latency in the stats line (`rx: cam Nf/Nd 16/s ~3ms`), peer camera/screen on canvases, peer mic through a sequential-playhead PCM player that drops >350ms backlog (live beats lossless for a call-like demo). Mic capture uses `worker:false, bufferSize:4096` — big PCM chunks instead of ~375 socket messages/s.
|
|
6
|
+
- Docs: screen-share recipe + hidden-tab capture note in the Media section; README demo line mentions the media part.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 1.0.73
|
|
2
|
+
|
|
3
|
+
- Media viewer helpers — the consumer side of a media line in one call (`media-view`, additive): `Media.attachVideoCanvas(line, canvas)` (header-driven codec/size, busy-skip keep-latest, `stats()` with frames/drawn/perSec/ageMs), `Media.attachAudioPlayer(line, {maxBacklogSec})` (sequential-playhead PCM, live backlog drop, gesture-gated `enable()`), `Media.pipeMediaPublish(line, publish, {stamp})` (fire-and-forget publish pipe; the `Date.now()` stamp feeds viewer latency stats). DI for tests: `createBitmap` / `audioContext` injection points.
|
|
4
|
+
- Demo stand client rewired onto the helpers — the hand-written viewer plumbing (~90 lines of JPEG decode, PCM playhead, counters) collapses into one call per channel; behavior verified live (cam 30/s ~3ms, mic 12/s ~2ms, zero drops).
|
|
5
|
+
- Route hand-off under media load verified on the live stand: relay -> direct (real ICE/SDP) -> relay while camera + mic stream; zero errors, media rates/latency untouched through both switches (media rides the socket relay; peer-store routes are per-direction by design).
|
|
6
|
+
- Export `Media.toBytes` (binary view normalizer used by the helpers).
|
|
@@ -223,9 +223,16 @@ Audio source:
|
|
|
223
223
|
- `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
|
|
224
224
|
|
|
225
225
|
Video source:
|
|
226
|
-
- default snapshots, not a 30fps video stream:
|
|
226
|
+
- default snapshots, not a 30fps video stream: JPEG, `fps` default 3, `quality` default 0.82.
|
|
227
227
|
- each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
|
|
228
|
-
-
|
|
228
|
+
- capture is hidden-tab-proof by default (Chrome throttles hidden tabs three ways, each stage has its own escape): the tick comes from a Blob-worker timer (in-page `setInterval` drops to ~1/s), the frame comes from `ImageCapture.grabFrame()` when available (a hidden `<video>` stops painting; `<video>->canvas` stays as the fallback), and JPEG encode runs in a worker over a transferred `ImageBitmap`, returning a transferred `ArrayBuffer` — never a structured-cloned frame (main-thread `convertToBlob` is gated to ~1s per call when hidden). `worker: false` opts out of all three into the plain in-page path.
|
|
229
|
+
- one explicit dimension (`width` or `height`) scales the other proportionally from the track resolution, downscale-only; pass both to force an exact size. `grabFrame`'s ~50ms serial latency caps the pipeline around ~15-20fps regardless of `fps`.
|
|
230
|
+
|
|
231
|
+
Viewer helpers (`media-view`): the consumer side of any media line (local pair or RPC surface).
|
|
232
|
+
- `attachVideoCanvas(line, canvas, {createBitmap?, onError?})` — per-frame codec/size come from the 40-byte header, canvas resizes to follow; decode overload is busy-skipped (keep-latest, `stats().frames` vs `stats().drawn` shows the gap); `createBitmap` injects a custom decoder (tests, OffscreenCanvas pipelines).
|
|
233
|
+
- `attachAudioPlayer(line, {maxBacklogSec? = 0.35, audioContext?, onError?})` — pcm16/float32 through a sequential playhead; a backlog past `maxBacklogSec` is dropped and the playhead rebases near "now" (live beats lossless; `stats().dropped` counts rebases). `enable()` must come from a user gesture (browser autoplay rules); `audioContext` injects a factory for tests/custom routing.
|
|
234
|
+
- `pipeMediaPublish(line, publish, {stamp? = true, onError?})` — fire-and-forget pipe into an RPC call; the default `Date.now()` stamp is what viewer `stats().ageMs` measures against. Both attach helpers also expose `stats().perSec` (rolling 1s rate).
|
|
235
|
+
- Oracle: `replay/media-view.test.ts`.
|
|
229
236
|
|
|
230
237
|
Replay/RPC wiring:
|
|
231
238
|
```ts
|
|
@@ -529,13 +536,27 @@ acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
|
|
|
529
536
|
// responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
|
|
530
537
|
// the incoming datachannel; accept(env) validates session material and rejects with a loud revoke
|
|
531
538
|
// (the initiator fails fast, not by timeout). Repeated offer for a pair recreates the session.
|
|
532
|
-
Peer.createPatchRelayJournal({history
|
|
533
|
-
// server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM
|
|
534
|
-
// (dedup by seq; a root patch with a LOWER seq = owner restart, legitimate reset point), keyframe is
|
|
535
|
-
// folded server-side (late joiners don't need the owner online), frame condenses last-patch-per-path.
|
|
539
|
+
Peer.createPatchRelayJournal({history?, gap?: 'resume'|'sacred'}) -> {push(env) -> true|false|{seq}, remote, gap, seq(), snapshot(), close()}
|
|
540
|
+
// server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM.
|
|
536
541
|
// Owner seq space is the point: relay and direct routes share coordinates -> hand-off is a seq resume.
|
|
537
|
-
//
|
|
538
|
-
//
|
|
542
|
+
// CORRECTNESS CONTRACT (gap = the SERVER's data-type decision):
|
|
543
|
+
// 'resume' (default, folding): keyframe folded server-side (late joiners never need the owner online);
|
|
544
|
+
// a ROOT patch always resets the journal (owner restart AND keyframe repair share one rule); a
|
|
545
|
+
// non-root envelope with a seq gap — and a non-root FIRST envelope (partial-state lie) — is
|
|
546
|
+
// REJECTED as {seq: N}: that coordinate IS the repair request, no separate handshake exists.
|
|
547
|
+
// 'sacred': the journal never invents — no folded keyframe, no root-reset, strict contiguity only;
|
|
548
|
+
// frame() on an evicted tail THROWS. For data where an invented snapshot is unacceptable.
|
|
549
|
+
// Duplicates (reconnect/repair overlap) are idempotent no-ops. Rejection never corrupts the fold.
|
|
550
|
+
// remote is ReplayRemote-shaped (+ additive `seq()` for publisher resync) and rpc-exposable as is
|
|
551
|
+
// (line is a REAL Listen — the rpc layer detects listen nodes by registry, a hand-rolled
|
|
552
|
+
// {on: cb => ...} wrapper would not stream).
|
|
553
|
+
// Publisher side (createPeerClient): {journal?: 'resume'|'sacred', repair?: 'tail'|'keyframe',
|
|
554
|
+
// onPublishError?, resync()}. 'tail' = missed envelopes verbatim (falls back to a root keyframe if
|
|
555
|
+
// the local journal evicted them — resume only); 'keyframe' = one cheap root reset (ephemeral state).
|
|
556
|
+
// TYPE RULE, not a runtime check: journal:'sacred' narrows repair to 'tail' (tPublishRepair<J>) —
|
|
557
|
+
// lie about the journal kind and the relay simply keeps rejecting you (loud via onPublishError).
|
|
558
|
+
// resync() = call after transport reconnect: compares relay seq() with the local line, repairs the
|
|
559
|
+
// gap without waiting for the next write. Oracle: replay/peer-repair.test.ts (full gap matrix).
|
|
539
560
|
// The full SDK on top (createPeerHost/createPeerClient) is most-used surface -> wenay-common2.md.
|
|
540
561
|
serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
|
|
541
562
|
// replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
|
package/doc/wenay-common2.md
CHANGED
|
@@ -151,8 +151,13 @@ Media.createAudioSource({format?: 'int16'|'float32', mode?: 'pcm'|'record', repl
|
|
|
151
151
|
Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit, listen] & control
|
|
152
152
|
control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> · stop() · getStats() · setDevice(id) · listDevices() · state
|
|
153
153
|
Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
|
|
154
|
+
|
|
155
|
+
// viewer/publisher one-liners (the demo stand is built on these):
|
|
156
|
+
Media.attachVideoCanvas(line, canvas, {onError?}) -> {stats(), off} // decode+render any video line; codec/size come from frame headers
|
|
157
|
+
Media.attachAudioPlayer(line, {maxBacklogSec? = 0.35}) -> {enable(), disable(), enabled, stats(), off} // live PCM playback, backlog drops
|
|
158
|
+
Media.pipeMediaPublish(line, publish, {stamp? = true, onError?}) -> off // source -> RPC publish fn; stamp lets viewers measure latency
|
|
154
159
|
```
|
|
155
|
-
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (
|
|
160
|
+
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (JPEG, low fps for vision) captured hidden-tab-proof: a worker timer ticks (setInterval is throttled to ~1/s in hidden tabs), `ImageCapture.grabFrame()` reads the track (a hidden `<video>` stops painting), and JPEG encode runs in a worker (main-thread `convertToBlob` stalls ~1s hidden) — `worker:false` opts back into the plain in-page path. Screen share is the same video source with an injected stream: `createVideoSource({stream: () => navigator.mediaDevices.getDisplayMedia({video: true})})`. Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today. Living example: the demo stand (`npm run demo`) streams camera / mic / screen share between two tabs through a tiny server-side relay of replay lines (`demo/server.ts` + `demo/client.ts`).
|
|
156
161
|
|
|
157
162
|
## 🤝 Peer — accounts see each other's stores (one-call SDK)
|
|
158
163
|
> `import { Peer } from "wenay-common2"` or `import * as Peer from "wenay-common2/peer"`.
|
|
@@ -185,7 +190,13 @@ me.onRoute(ev => {}) // route transitions for metrics/UI
|
|
|
185
190
|
```
|
|
186
191
|
Key property: the relay journal stores the owner's envelopes VERBATIM (owner seq space), so a
|
|
187
192
|
relay <-> direct hand-off is a plain seq resume — no keyframe reset, no gaps, no dups. Late joiners
|
|
188
|
-
get a keyframe folded server-side even while the owner is offline.
|
|
193
|
+
get a keyframe folded server-side even while the owner is offline.
|
|
194
|
+
Reconnect correctness is self-healing: a publisher gap makes the relay reject the push WITH its last
|
|
195
|
+
seq, and the client repairs from that coordinate automatically (`repair: 'tail'` lossless (default)
|
|
196
|
+
| `'keyframe'` cheap reset for ephemeral state). Server declares journal semantics
|
|
197
|
+
(`createPeerHost({gap: 'resume' | 'sacred'})`); a declared `journal: 'sacred'` TYPE-forbids the cheap
|
|
198
|
+
repair. `me.resync()` after a transport reconnect repairs without waiting for the next write;
|
|
199
|
+
failures surface via `onPublishError`, never silently. Policy/session material:
|
|
189
200
|
`createPeerClient({session, accept, policy})` + host `authorize` — see rare docs for the envelope
|
|
190
201
|
contract and the underlying primitives (`createRouteCoordinator`, `createSignalHub`,
|
|
191
202
|
`createWebRtcConnector`). Oracle: `replay/peer-sdk.test.ts`.
|
|
@@ -14,6 +14,7 @@ export type MediaReplayOpts = true | ReplayListenUseOptions<[Uint8Array]>;
|
|
|
14
14
|
export type AudioSourceOpts = {
|
|
15
15
|
sourceId?: string;
|
|
16
16
|
deviceId?: string;
|
|
17
|
+
stream?: any;
|
|
17
18
|
mode?: 'pcm' | 'record';
|
|
18
19
|
format?: 'int16' | 'float32';
|
|
19
20
|
channels?: number;
|
|
@@ -28,6 +29,7 @@ export type AudioSourceOpts = {
|
|
|
28
29
|
export type VideoSourceOpts = {
|
|
29
30
|
sourceId?: string;
|
|
30
31
|
deviceId?: string;
|
|
32
|
+
stream?: any;
|
|
31
33
|
fps?: number;
|
|
32
34
|
width?: number;
|
|
33
35
|
height?: number;
|
|
@@ -88,6 +90,7 @@ export type DecodedMediaFrame = MediaFrameMeta & {
|
|
|
88
90
|
export declare const MEDIA_FRAME_MAGIC = 1464028466;
|
|
89
91
|
export declare const MEDIA_FRAME_VERSION = 1;
|
|
90
92
|
export declare const MEDIA_FRAME_HEADER_BYTES = 40;
|
|
93
|
+
export declare function toBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array<ArrayBufferLike>;
|
|
91
94
|
export declare function encodeMediaFrame(meta: MediaFrameMeta, payload: ArrayBuffer | ArrayBufferView): Uint8Array<ArrayBuffer>;
|
|
92
95
|
export declare function decodeMediaFrame(frameLike: ArrayBuffer | ArrayBufferView): DecodedMediaFrame;
|
|
93
96
|
export declare function createAudioSource(opts?: AudioSourceOpts): MediaSource;
|