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.
- package/README.md +5 -3
- package/demo/client.ts +230 -3
- package/demo/index.html +37 -0
- package/demo/server.ts +91 -4
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +233 -212
- 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/changes/1.0.74.md +12 -0
- package/doc/wenay-common2-rare.md +684 -627
- package/doc/wenay-common2.md +50 -5
- package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
- package/lib/Common/events/route-signal-webrtc.js +15 -5
- 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 +210 -0
- package/lib/Common/peer/peer-call.d.ts +65 -0
- package/lib/Common/peer/peer-call.js +173 -0
- package/lib/Common/peer/peer-client.d.ts +21 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +22 -5
- package/lib/Common/peer/peer-host.js +49 -5
- package/lib/Common/peer/peer-index.d.ts +2 -0
- package/lib/Common/peer/peer-index.js +2 -0
- package/lib/Common/peer/peer-media-relay.d.ts +22 -0
- package/lib/Common/peer/peer-media-relay.js +155 -0
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/listen-core.test.ts +1 -2
- package/package.json +1 -4
- package/replay/media-view.test.ts +164 -0
- package/replay/peer-call.test.ts +226 -0
- package/replay/peer-repair.test.ts +184 -0
- package/doc/changes/1.0.63.md +0 -8
- package/doc/changes/1.0.64.md +0 -10
package/README.md
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
## Living examples (shipped in the npm package)
|
|
14
14
|
|
|
15
|
-
- [`demo/`](demo/) — runnable
|
|
16
|
-
Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key
|
|
15
|
+
- [`demo/`](demo/) — runnable from a repository checkout (`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; 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
|
+
- The package ships readable example sources, not installed CLI scripts. Examples import from the
|
|
21
|
+
repo's `src/`; in application code the same API comes from
|
|
20
22
|
`wenay-common2` / `wenay-common2/peer` / `wenay-common2/replay` / `wenay-common2/observe`.
|
package/demo/client.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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 messenger-style calls: presence shows who is online, the call button rings
|
|
5
|
+
// the peer over the SAME signal hub, and media (camera / mic / screen) attaches
|
|
6
|
+
// only while the call is active — the server's watch ACL follows the call.
|
|
4
7
|
import {io} from 'socket.io-client'
|
|
5
8
|
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
6
|
-
import {createPeerClient} from '../src/Common/peer/peer-index'
|
|
9
|
+
import {callPortOf, createCallManager, createPeerClient} from '../src/Common/peer/peer-index'
|
|
10
|
+
import {attachAudioPlayer, attachVideoCanvas, createAudioSource, createVideoSource, pipeMediaPublish} from '../src/Common/media/media-index'
|
|
7
11
|
|
|
8
12
|
type World = {cursor: {x: number, y: number}, color: string, name: string}
|
|
9
13
|
|
|
@@ -24,7 +28,7 @@ function log(line: string) {
|
|
|
24
28
|
|
|
25
29
|
async function main() {
|
|
26
30
|
document.title = `peer ${me}`
|
|
27
|
-
el('who').textContent = `me: ${me} ·
|
|
31
|
+
el('who').textContent = `me: ${me} · peer: ${other}`
|
|
28
32
|
|
|
29
33
|
const hub = createRpcClientHub(
|
|
30
34
|
() => io({transports: ['websocket'], auth: {account: me}}),
|
|
@@ -34,13 +38,30 @@ async function main() {
|
|
|
34
38
|
await clients.app.readyStrict()
|
|
35
39
|
log('rpc connected; legacy serverTime() = ' + await clients.app.func.serverTime())
|
|
36
40
|
|
|
37
|
-
// debug tap: every signaling envelope this account receives
|
|
41
|
+
// debug tap: every signaling envelope this account receives (webrtc AND call types)
|
|
38
42
|
;(clients.app.func.peer.signal.signals as any).on((env: any) => {
|
|
39
43
|
log(`sig<- ${env.type} ${env.from}->${env.to}` +
|
|
40
44
|
(env.sdp ? ` sdp.len=${String(env.sdp).length}` : '') +
|
|
41
45
|
(env.candidate ? ` cand=${JSON.stringify(env.candidate).slice(0, 70)}` : ''))
|
|
42
46
|
})
|
|
43
47
|
|
|
48
|
+
// ============== presence: is the peer online? ==============
|
|
49
|
+
const onlineEl = el('online')
|
|
50
|
+
const onlineSet = new Set<string>()
|
|
51
|
+
function renderPresence() {
|
|
52
|
+
const up = onlineSet.has(other)
|
|
53
|
+
onlineEl.textContent = up ? `● ${other} online` : `○ ${other} offline`
|
|
54
|
+
onlineEl.style.color = up ? '#2e7d32' : '#999'
|
|
55
|
+
}
|
|
56
|
+
// subscribe FIRST, then list() — the changes feed is a plain edge Listen
|
|
57
|
+
;(clients.app.func.peer.presence.changes as any).on((ch: any) => {
|
|
58
|
+
if (ch.online) onlineSet.add(ch.account); else onlineSet.delete(ch.account)
|
|
59
|
+
if (ch.account != me) log(`presence: ${ch.account} ${ch.online ? 'online' : 'offline'}`)
|
|
60
|
+
renderPresence()
|
|
61
|
+
})
|
|
62
|
+
for (const account of await clients.app.func.peer.presence.list()) onlineSet.add(account)
|
|
63
|
+
renderPresence()
|
|
64
|
+
|
|
44
65
|
const client = createPeerClient<World>({
|
|
45
66
|
remote: clients.app.func.peer,
|
|
46
67
|
account: me,
|
|
@@ -95,6 +116,212 @@ async function main() {
|
|
|
95
116
|
const res = await peer.reinterposeRelay('manual')
|
|
96
117
|
log(res.ok ? 'back on relay' : `re-interpose failed: ${String(res.reason)}`)
|
|
97
118
|
})
|
|
119
|
+
|
|
120
|
+
// ============== calls: ring/accept/decline over the same signal hub ==============
|
|
121
|
+
const media = setupMedia(clients.app.func.media)
|
|
122
|
+
const calls = createCallManager({port: callPortOf(clients.app.func.peer), self: me})
|
|
123
|
+
calls.ready.then(() => log('call signaling ready'))
|
|
124
|
+
|
|
125
|
+
const callBtn = el('call') as HTMLButtonElement
|
|
126
|
+
const acceptBtn = el('accept') as HTMLButtonElement
|
|
127
|
+
const declineBtn = el('decline') as HTMLButtonElement
|
|
128
|
+
const callStateEl = el('callState')
|
|
129
|
+
let call: any = null
|
|
130
|
+
|
|
131
|
+
function renderCallUi() {
|
|
132
|
+
const state = call?.state()
|
|
133
|
+
const incoming = call?.direction == 'in' && state == 'ringing'
|
|
134
|
+
callBtn.hidden = incoming
|
|
135
|
+
acceptBtn.hidden = !incoming
|
|
136
|
+
declineBtn.hidden = !incoming
|
|
137
|
+
callBtn.textContent = state == 'active' ? '📞 hang up'
|
|
138
|
+
: state == 'ringing' ? '📞 cancel…'
|
|
139
|
+
: `📞 call ${other}`
|
|
140
|
+
callStateEl.textContent = !call ? ''
|
|
141
|
+
: state == 'ringing' ? (incoming ? `${call.peer} is calling…` : `ringing ${call.peer}…`)
|
|
142
|
+
: state == 'active' ? `in call with ${call.peer}`
|
|
143
|
+
: ''
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function bindCall(next: any) {
|
|
147
|
+
call = next
|
|
148
|
+
call.changed.on(function onCallState(state: string) {
|
|
149
|
+
log(`call ${state}${call?.reason() ? ` (${call.reason()})` : ''}`)
|
|
150
|
+
if (state == 'active') media.attachPeer()
|
|
151
|
+
if (state == 'ended') {
|
|
152
|
+
media.detachPeer()
|
|
153
|
+
call = null
|
|
154
|
+
}
|
|
155
|
+
renderCallUi()
|
|
156
|
+
})
|
|
157
|
+
renderCallUi()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
callBtn.addEventListener('click', function onCallButton() {
|
|
161
|
+
if (call) { call.hangup(); return }
|
|
162
|
+
log(`calling ${other}...`)
|
|
163
|
+
bindCall(calls.call(other, {kinds: ['cam', 'mic', 'screen']}))
|
|
164
|
+
})
|
|
165
|
+
acceptBtn.addEventListener('click', () => call?.accept())
|
|
166
|
+
declineBtn.addEventListener('click', () => call?.decline())
|
|
167
|
+
calls.rings.on(function onIncomingRing(incoming: any) {
|
|
168
|
+
log(`incoming call from ${incoming.peer}`)
|
|
169
|
+
bindCall(incoming)
|
|
170
|
+
})
|
|
171
|
+
renderCallUi()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============== media: capture own cam/mic/screen; watch the peer's WHILE IN CALL ==============
|
|
175
|
+
type tMediaKind = 'cam' | 'mic' | 'screen'
|
|
176
|
+
|
|
177
|
+
function setupMedia(media: any) {
|
|
178
|
+
// -------- publish own frames through the relay (fire-and-forget) --------
|
|
179
|
+
function pipePublish(kind: tMediaKind, src: any) {
|
|
180
|
+
pipeMediaPublish(src[1], (frame, sentAt) => media.publish(kind, frame, sentAt), {
|
|
181
|
+
onError: e => log(`media publish ${kind} failed: ${e}`),
|
|
182
|
+
})
|
|
183
|
+
return src
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const camResEl = el('camRes') as HTMLSelectElement
|
|
187
|
+
function makeCam() {
|
|
188
|
+
// library fps default (3) targets machine vision; a live stand wants motion
|
|
189
|
+
return pipePublish('cam', createVideoSource({sourceId: 'cam', fps: 60, width: Number(camResEl.value) || 640, codec: 'jpeg'}))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sources = {
|
|
193
|
+
cam: makeCam(),
|
|
194
|
+
screen: pipePublish('screen', createVideoSource({
|
|
195
|
+
sourceId: 'screen',
|
|
196
|
+
fps: 10,
|
|
197
|
+
codec: 'jpeg',
|
|
198
|
+
quality: 0.5, // full-screen JPEGs get large fast; favor latency
|
|
199
|
+
// the documented `stream` injection point: skip getUserMedia, bring getDisplayMedia
|
|
200
|
+
stream: () => (navigator.mediaDevices as any).getDisplayMedia({video: true}),
|
|
201
|
+
})),
|
|
202
|
+
// big buffers on purpose: the worklet's 128-sample chunks would be ~375 socket
|
|
203
|
+
// messages per second and drown the shared connection (lag for everything)
|
|
204
|
+
mic: pipePublish('mic', createAudioSource({sourceId: 'mic', worklet: false, bufferSize: 4096})),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// resolution stress test: swap the camera source on the fly, keep it live if it was
|
|
208
|
+
camResEl.addEventListener('change', async function onCamResChange() {
|
|
209
|
+
const wasLive = sources.cam.state == 'live'
|
|
210
|
+
sources.cam.stop()
|
|
211
|
+
sources.cam = makeCam()
|
|
212
|
+
if (wasLive) {
|
|
213
|
+
const state = await sources.cam.start()
|
|
214
|
+
log(`cam @${camResEl.value}p: ${state}`)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// -------- capture toggles --------
|
|
219
|
+
function bindToggle(id: string, kind: tMediaKind, label: string) {
|
|
220
|
+
const btn = el(id) as HTMLButtonElement
|
|
221
|
+
btn.addEventListener('click', async function toggleCapture() {
|
|
222
|
+
const src = sources[kind]
|
|
223
|
+
if (src.state == 'live') {
|
|
224
|
+
src.stop()
|
|
225
|
+
btn.textContent = label
|
|
226
|
+
log(`${kind}: stopped`)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
btn.textContent = `${label} …`
|
|
230
|
+
const state = await src.start()
|
|
231
|
+
btn.textContent = state == 'live' ? `${label} ⏹` : label
|
|
232
|
+
log(`${kind}: ${state}${state != 'live' && src.getStats().error ? ' — ' + src.getStats().error : ''}`)
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
bindToggle('cam', 'cam', '📷 camera')
|
|
236
|
+
bindToggle('mic', 'mic', '🎙 mic')
|
|
237
|
+
bindToggle('screen', 'screen', '🖥 screen')
|
|
238
|
+
|
|
239
|
+
// -------- peer viewers: attached only while a call is active --------
|
|
240
|
+
// The server's watch ACL denies media.watch[peer] outside a call, so the viewers
|
|
241
|
+
// are created on 'active' and torn down on 'ended' (one-time DOM wiring below).
|
|
242
|
+
for (const id of ['peerCam', 'peerScreen']) {
|
|
243
|
+
const canvas = el(id) as HTMLCanvasElement
|
|
244
|
+
canvas.addEventListener('click', function goFullscreen() {
|
|
245
|
+
void (document.fullscreenElement == canvas ? document.exitFullscreen() : canvas.requestFullscreen?.())
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
const audioBtn = el('audio') as HTMLButtonElement
|
|
249
|
+
type PeerViews = {
|
|
250
|
+
cam: ReturnType<typeof attachVideoCanvas>
|
|
251
|
+
screen: ReturnType<typeof attachVideoCanvas>
|
|
252
|
+
player: ReturnType<typeof attachAudioPlayer>
|
|
253
|
+
}
|
|
254
|
+
let views: PeerViews | null = null
|
|
255
|
+
|
|
256
|
+
audioBtn.addEventListener('click', function togglePeerAudio() {
|
|
257
|
+
if (!views) return
|
|
258
|
+
if (views.player.enabled) {
|
|
259
|
+
views.player.disable()
|
|
260
|
+
audioBtn.textContent = '🔊 peer audio'
|
|
261
|
+
} else {
|
|
262
|
+
views.player.enable()
|
|
263
|
+
audioBtn.textContent = '🔊 peer audio ⏹'
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
function attachPeer() {
|
|
268
|
+
if (views) return
|
|
269
|
+
const watch = media.watch[other]
|
|
270
|
+
views = {
|
|
271
|
+
cam: attachVideoCanvas(watch.cam, el('peerCam'), {onError: e => log('video frame render failed: ' + e)}),
|
|
272
|
+
screen: attachVideoCanvas(watch.screen, el('peerScreen'), {onError: e => log('screen frame render failed: ' + e)}),
|
|
273
|
+
player: attachAudioPlayer(watch.mic, {onError: e => log('audio frame failed: ' + e)}),
|
|
274
|
+
}
|
|
275
|
+
audioBtn.disabled = false
|
|
276
|
+
log(`watching ${other}'s media (call active)`)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function detachPeer() {
|
|
280
|
+
if (!views) return
|
|
281
|
+
views.cam.off()
|
|
282
|
+
views.screen.off()
|
|
283
|
+
views.player.disable()
|
|
284
|
+
views.player.off()
|
|
285
|
+
views = null
|
|
286
|
+
audioBtn.disabled = true
|
|
287
|
+
audioBtn.textContent = '🔊 peer audio'
|
|
288
|
+
for (const id of ['peerCam', 'peerScreen']) {
|
|
289
|
+
const canvas = el(id) as HTMLCanvasElement
|
|
290
|
+
canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height)
|
|
291
|
+
}
|
|
292
|
+
log('peer media detached (call ended)')
|
|
293
|
+
}
|
|
294
|
+
audioBtn.disabled = true
|
|
295
|
+
|
|
296
|
+
// -------- stats line: own capture + helper-provided rx metrics --------
|
|
297
|
+
const statsEl = el('mediaStats')
|
|
298
|
+
let prevTx = {cam: 0, mic: 0, screen: 0}
|
|
299
|
+
setInterval(function renderMediaStats() {
|
|
300
|
+
const parts: string[] = []
|
|
301
|
+
const nextTx = {cam: 0, mic: 0, screen: 0}
|
|
302
|
+
for (const kind of ['cam', 'mic', 'screen'] as const) {
|
|
303
|
+
const s = sources[kind].getStats()
|
|
304
|
+
nextTx[kind] = s.frames
|
|
305
|
+
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)}` : ''}`)
|
|
306
|
+
}
|
|
307
|
+
prevTx = nextTx
|
|
308
|
+
if (views) {
|
|
309
|
+
const cam = views.cam.stats()
|
|
310
|
+
const screen = views.screen.stats()
|
|
311
|
+
const mic = views.player.stats()
|
|
312
|
+
const caps = [['peerCam', 'peer camera', cam], ['peerScreen', 'peer screen', screen]] as const
|
|
313
|
+
for (const [id, caption, s] of caps) {
|
|
314
|
+
if (s.width) el(id + 'Cap').textContent = `${caption} · ${s.width}×${s.height} · click = fullscreen`
|
|
315
|
+
}
|
|
316
|
+
if (cam.frames || screen.frames || mic.frames) parts.push(
|
|
317
|
+
`rx: cam ${cam.frames}f/${cam.drawn}d ${cam.perSec}/s ~${cam.ageMs}ms` +
|
|
318
|
+
` · screen ${screen.frames}f/${screen.drawn}d ${screen.perSec}/s ~${screen.ageMs}ms` +
|
|
319
|
+
` · mic ${mic.frames}f ${mic.perSec}/s ~${mic.ageMs}ms`)
|
|
320
|
+
}
|
|
321
|
+
statsEl.textContent = parts.join(' · ')
|
|
322
|
+
}, 1000)
|
|
323
|
+
|
|
324
|
+
return {attachPeer, detachPeer}
|
|
98
325
|
}
|
|
99
326
|
|
|
100
327
|
main().catch(e => { console.error(e); log('FATAL: ' + e) })
|
package/demo/index.html
CHANGED
|
@@ -11,17 +11,54 @@
|
|
|
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>
|
|
17
24
|
<h3>wenay-common2 — shared cursors (relay ⇄ WebRTC direct)</h3>
|
|
18
25
|
<div id="bar">
|
|
19
26
|
<span id="who">connecting…</span>
|
|
27
|
+
<span id="online"></span>
|
|
20
28
|
<button id="direct">Go direct</button>
|
|
21
29
|
<button id="relay">Back to relay</button>
|
|
22
30
|
<span id="route"></span>
|
|
23
31
|
</div>
|
|
32
|
+
<div id="bar">
|
|
33
|
+
<button id="call">📞 call</button>
|
|
34
|
+
<button id="accept" hidden>✅ accept</button>
|
|
35
|
+
<button id="decline" hidden>⛔ decline</button>
|
|
36
|
+
<span id="callState"></span>
|
|
37
|
+
</div>
|
|
24
38
|
<canvas id="canvas" width="640" height="360"></canvas>
|
|
39
|
+
<div id="bar">
|
|
40
|
+
<button id="cam">📷 camera</button>
|
|
41
|
+
<select id="camRes" title="camera capture width">
|
|
42
|
+
<option value="320">320p</option>
|
|
43
|
+
<option value="640" selected>640p</option>
|
|
44
|
+
<option value="1280">1280p</option>
|
|
45
|
+
<option value="1920">1920p</option>
|
|
46
|
+
</select>
|
|
47
|
+
<button id="mic">🎙 mic</button>
|
|
48
|
+
<button id="screen">🖥 screen</button>
|
|
49
|
+
<button id="audio">🔊 peer audio</button>
|
|
50
|
+
<span id="mediaStats"></span>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="mediaView">
|
|
53
|
+
<figure>
|
|
54
|
+
<figcaption id="peerCamCap">peer camera · click = fullscreen</figcaption>
|
|
55
|
+
<canvas id="peerCam" width="320" height="240"></canvas>
|
|
56
|
+
</figure>
|
|
57
|
+
<figure>
|
|
58
|
+
<figcaption id="peerScreenCap">peer screen · click = fullscreen</figcaption>
|
|
59
|
+
<canvas id="peerScreen" width="480" height="270"></canvas>
|
|
60
|
+
</figure>
|
|
61
|
+
</div>
|
|
25
62
|
<div id="log"></div>
|
|
26
63
|
<script src="client.js"></script>
|
|
27
64
|
</body>
|
package/demo/server.ts
CHANGED
|
@@ -5,18 +5,100 @@ 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 {
|
|
8
|
+
import {SignalEnvelope} from '../src/Common/events/route-signal-webrtc'
|
|
9
|
+
import {createMediaRelay, 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
|
|
|
13
|
-
|
|
14
|
+
// ============== media relay + call-driven watch ACL ==============
|
|
15
|
+
// The signal hub is the single policy boundary, but authorization still needs a tiny
|
|
16
|
+
// server-owned call lifecycle: an arbitrary forged `accept` must never grant media.
|
|
17
|
+
function createCallWatchPolicy() {
|
|
18
|
+
type Call = {caller: string, callee: string, state: 'ringing' | 'active', timer: any}
|
|
19
|
+
const calls = new Map<string, Call>()
|
|
20
|
+
const grants = new Set<string>()
|
|
21
|
+
|
|
22
|
+
function grant(a: string, b: string, on: boolean) {
|
|
23
|
+
if (on) { grants.add(a + '|' + b); grants.add(b + '|' + a) }
|
|
24
|
+
else { grants.delete(a + '|' + b); grants.delete(b + '|' + a) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function finish(pair: string, reason: string) {
|
|
28
|
+
const call = calls.get(pair)
|
|
29
|
+
if (!call) return
|
|
30
|
+
calls.delete(pair)
|
|
31
|
+
clearTimeout(call.timer)
|
|
32
|
+
grant(call.caller, call.callee, false)
|
|
33
|
+
console.log(`[demo] call down: ${call.caller} <-> ${call.callee} (${reason})`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function authorize(env: SignalEnvelope) {
|
|
37
|
+
if (env.type == 'ring') {
|
|
38
|
+
if (!env.pair.startsWith('call:') || env.from == env.to || calls.has(env.pair)) return false
|
|
39
|
+
const timer = setTimeout(function expireServerCall() { finish(env.pair, 'expired') }, 35_000)
|
|
40
|
+
timer.unref?.()
|
|
41
|
+
calls.set(env.pair, {caller: env.from, callee: env.to, state: 'ringing', timer})
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
if (env.type != 'accept' && env.type != 'decline' && env.type != 'hangup') return true
|
|
45
|
+
const call = calls.get(env.pair)
|
|
46
|
+
if (!call) return false
|
|
47
|
+
const reverse = env.from == call.callee && env.to == call.caller
|
|
48
|
+
const participant = (env.from == call.caller && env.to == call.callee) || reverse
|
|
49
|
+
if (env.type == 'accept') {
|
|
50
|
+
if (call.state != 'ringing' || !reverse) return false
|
|
51
|
+
call.state = 'active'
|
|
52
|
+
clearTimeout(call.timer)
|
|
53
|
+
call.timer = null
|
|
54
|
+
grant(call.caller, call.callee, true)
|
|
55
|
+
console.log(`[demo] call up: ${call.caller} <-> ${call.callee} (media granted)`)
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
if (env.type == 'decline' && (!reverse || call.state != 'ringing')) return false
|
|
59
|
+
if (!participant) return false
|
|
60
|
+
finish(env.pair, env.type)
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dropAccount(account: string) {
|
|
65
|
+
for (const [pair, call] of calls) {
|
|
66
|
+
if (call.caller == account || call.callee == account) finish(pair, 'offline')
|
|
67
|
+
}
|
|
68
|
+
for (const key of Array.from(grants)) {
|
|
69
|
+
if (key.startsWith(account + '|') || key.endsWith('|' + account)) grants.delete(key)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
authorize,
|
|
75
|
+
canWatch: (watcher: string, owner: string) => grants.has(watcher + '|' + owner),
|
|
76
|
+
dropAccount,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const callPolicy = createCallWatchPolicy()
|
|
81
|
+
const media = createMediaRelay({
|
|
82
|
+
lines: {cam: 'video', mic: 'audio', screen: 'video'},
|
|
83
|
+
canWatch: callPolicy.canWatch,
|
|
84
|
+
})
|
|
85
|
+
const host = createPeerHost({authorize: callPolicy.authorize})
|
|
86
|
+
|
|
87
|
+
// Presence cleanup retires media lines and every call/grant involving the account.
|
|
88
|
+
host.presence.changes.on(function onPresenceEdge(ch) {
|
|
89
|
+
console.log(`[demo] presence: ${ch.account} ${ch.online ? 'online' : 'offline'}`)
|
|
90
|
+
if (ch.online) return
|
|
91
|
+
callPolicy.dropAccount(ch.account)
|
|
92
|
+
media.dropAccount(ch.account)
|
|
93
|
+
})
|
|
94
|
+
|
|
14
95
|
const app = express()
|
|
15
96
|
app.use(express.static(path.resolve(__dirname, 'public')))
|
|
16
97
|
app.get('/', (_req, res) => res.sendFile(path.resolve(__dirname, 'public', 'index.html')))
|
|
17
98
|
|
|
18
99
|
const httpServer = createServer(app)
|
|
19
|
-
|
|
100
|
+
// screen-share JPEG frames can exceed the 1MB Socket.IO default
|
|
101
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
20
102
|
|
|
21
103
|
ioServer.on('connection', function onDemoConnection(socket) {
|
|
22
104
|
const account = String(socket.handshake.auth?.account ?? 'anon')
|
|
@@ -30,6 +112,11 @@ ioServer.on('connection', function onDemoConnection(socket) {
|
|
|
30
112
|
// legacy key on the SAME connection — the SDK does not displace old code
|
|
31
113
|
serverTime: () => new Date().toISOString(),
|
|
32
114
|
peer: peer.fragment,
|
|
115
|
+
media: {
|
|
116
|
+
publish: media.publishOf(account),
|
|
117
|
+
// policy-gated view: THIS connection's account is what canWatch receives
|
|
118
|
+
watch: media.watchOf(account),
|
|
119
|
+
},
|
|
33
120
|
},
|
|
34
121
|
disconnectListen,
|
|
35
122
|
})
|
|
@@ -37,7 +124,7 @@ ioServer.on('connection', function onDemoConnection(socket) {
|
|
|
37
124
|
})
|
|
38
125
|
|
|
39
126
|
httpServer.listen(PORT, function onDemoListen() {
|
|
40
|
-
console.log('[demo] shared-cursor stand is up:')
|
|
127
|
+
console.log('[demo] shared-cursor + calls stand is up:')
|
|
41
128
|
console.log(` tab A: http://localhost:${PORT}/?me=a&peer=b`)
|
|
42
129
|
console.log(` tab B: http://localhost:${PORT}/?me=b&peer=a`)
|
|
43
130
|
})
|
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 repository; the npm package carries its readable source.
|
|
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/`.
|