wenay-common2 1.0.73 → 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 +3 -2
- package/demo/client.ts +137 -32
- package/demo/index.html +7 -0
- package/demo/server.ts +79 -42
- package/doc/INTENT.md +31 -31
- package/doc/ROADMAP.md +233 -230
- package/doc/changes/1.0.74.md +12 -0
- package/doc/wenay-common2-rare.md +684 -648
- package/doc/wenay-common2.md +37 -3
- 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-view.d.ts +2 -2
- package/lib/Common/media/media-view.js +23 -4
- 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 +9 -0
- package/lib/Common/peer/peer-host.d.ts +14 -1
- package/lib/Common/peer/peer-host.js +48 -4
- 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/observe/listen-core.test.ts +1 -2
- package/package.json +1 -4
- package/replay/media-view.test.ts +4 -4
- package/replay/peer-call.test.ts +226 -0
- package/doc/changes/1.0.64.md +0 -10
package/README.md
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
## Living examples (shipped in the npm package)
|
|
14
14
|
|
|
15
|
-
- [`demo/`](demo/) — runnable
|
|
15
|
+
- [`demo/`](demo/) — runnable from a repository checkout (`npm run demo`): shared cursors in two browser tabs over the
|
|
16
16
|
Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key; plus live media — camera,
|
|
17
17
|
microphone and screen share captured with the `Media` sources and streamed to the watching tab.
|
|
18
18
|
- [`replay/`](replay/) · [`observe/`](observe/) · [`oracle/`](oracle/) — the oracle suites CI runs on
|
|
19
19
|
every push; each file doubles as a worked usage example of one subsystem.
|
|
20
|
-
-
|
|
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
|
|
21
22
|
`wenay-common2` / `wenay-common2/peer` / `wenay-common2/replay` / `wenay-common2/observe`.
|
package/demo/client.ts
CHANGED
|
@@ -1,11 +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
|
|
5
|
-
//
|
|
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.
|
|
6
7
|
import {io} from 'socket.io-client'
|
|
7
8
|
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
8
|
-
import {createPeerClient} from '../src/Common/peer/peer-index'
|
|
9
|
+
import {callPortOf, createCallManager, createPeerClient} from '../src/Common/peer/peer-index'
|
|
9
10
|
import {attachAudioPlayer, attachVideoCanvas, createAudioSource, createVideoSource, pipeMediaPublish} from '../src/Common/media/media-index'
|
|
10
11
|
|
|
11
12
|
type World = {cursor: {x: number, y: number}, color: string, name: string}
|
|
@@ -27,23 +28,40 @@ function log(line: string) {
|
|
|
27
28
|
|
|
28
29
|
async function main() {
|
|
29
30
|
document.title = `peer ${me}`
|
|
30
|
-
el('who').textContent = `me: ${me} ·
|
|
31
|
+
el('who').textContent = `me: ${me} · peer: ${other}`
|
|
31
32
|
|
|
32
33
|
const hub = createRpcClientHub(
|
|
33
|
-
() => io({transports: ['websocket'], auth: {account: me
|
|
34
|
+
() => io({transports: ['websocket'], auth: {account: me}}),
|
|
34
35
|
r => ({app: r<any>('app')}) as const,
|
|
35
36
|
)
|
|
36
37
|
const clients = await hub.setToken(null)
|
|
37
38
|
await clients.app.readyStrict()
|
|
38
39
|
log('rpc connected; legacy serverTime() = ' + await clients.app.func.serverTime())
|
|
39
40
|
|
|
40
|
-
// debug tap: every signaling envelope this account receives
|
|
41
|
+
// debug tap: every signaling envelope this account receives (webrtc AND call types)
|
|
41
42
|
;(clients.app.func.peer.signal.signals as any).on((env: any) => {
|
|
42
43
|
log(`sig<- ${env.type} ${env.from}->${env.to}` +
|
|
43
44
|
(env.sdp ? ` sdp.len=${String(env.sdp).length}` : '') +
|
|
44
45
|
(env.candidate ? ` cand=${JSON.stringify(env.candidate).slice(0, 70)}` : ''))
|
|
45
46
|
})
|
|
46
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
|
+
|
|
47
65
|
const client = createPeerClient<World>({
|
|
48
66
|
remote: clients.app.func.peer,
|
|
49
67
|
account: me,
|
|
@@ -99,14 +117,65 @@ async function main() {
|
|
|
99
117
|
log(res.ok ? 'back on relay' : `re-interpose failed: ${String(res.reason)}`)
|
|
100
118
|
})
|
|
101
119
|
|
|
102
|
-
|
|
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()
|
|
103
172
|
}
|
|
104
173
|
|
|
105
|
-
// ============== media: capture own cam/mic/screen
|
|
174
|
+
// ============== media: capture own cam/mic/screen; watch the peer's WHILE IN CALL ==============
|
|
106
175
|
type tMediaKind = 'cam' | 'mic' | 'screen'
|
|
107
176
|
|
|
108
177
|
function setupMedia(media: any) {
|
|
109
|
-
// -------- publish own frames through the
|
|
178
|
+
// -------- publish own frames through the relay (fire-and-forget) --------
|
|
110
179
|
function pipePublish(kind: tMediaKind, src: any) {
|
|
111
180
|
pipeMediaPublish(src[1], (frame, sentAt) => media.publish(kind, frame, sentAt), {
|
|
112
181
|
onError: e => log(`media publish ${kind} failed: ${e}`),
|
|
@@ -167,31 +236,63 @@ function setupMedia(media: any) {
|
|
|
167
236
|
bindToggle('mic', 'mic', '🎙 mic')
|
|
168
237
|
bindToggle('screen', 'screen', '🖥 screen')
|
|
169
238
|
|
|
170
|
-
// --------
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
244
|
canvas.addEventListener('click', function goFullscreen() {
|
|
175
245
|
void (document.fullscreenElement == canvas ? document.exitFullscreen() : canvas.requestFullscreen?.())
|
|
176
246
|
})
|
|
177
|
-
const view = attachVideoCanvas(line, canvas, {onError: e => log('video frame render failed: ' + e)})
|
|
178
|
-
return {view, captionEl, caption}
|
|
179
247
|
}
|
|
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
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
|
+
|
|
185
256
|
audioBtn.addEventListener('click', function togglePeerAudio() {
|
|
186
|
-
if (
|
|
187
|
-
|
|
257
|
+
if (!views) return
|
|
258
|
+
if (views.player.enabled) {
|
|
259
|
+
views.player.disable()
|
|
188
260
|
audioBtn.textContent = '🔊 peer audio'
|
|
189
261
|
} else {
|
|
190
|
-
player.enable()
|
|
262
|
+
views.player.enable()
|
|
191
263
|
audioBtn.textContent = '🔊 peer audio ⏹'
|
|
192
264
|
}
|
|
193
265
|
})
|
|
194
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
|
+
|
|
195
296
|
// -------- stats line: own capture + helper-provided rx metrics --------
|
|
196
297
|
const statsEl = el('mediaStats')
|
|
197
298
|
let prevTx = {cam: 0, mic: 0, screen: 0}
|
|
@@ -204,19 +305,23 @@ function setupMedia(media: any) {
|
|
|
204
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)}` : ''}`)
|
|
205
306
|
}
|
|
206
307
|
prevTx = nextTx
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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`)
|
|
213
320
|
}
|
|
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
321
|
statsEl.textContent = parts.join(' · ')
|
|
219
322
|
}, 1000)
|
|
323
|
+
|
|
324
|
+
return {attachPeer, detachPeer}
|
|
220
325
|
}
|
|
221
326
|
|
|
222
327
|
main().catch(e => { console.error(e); log('FATAL: ' + e) })
|
package/demo/index.html
CHANGED
|
@@ -24,10 +24,17 @@
|
|
|
24
24
|
<h3>wenay-common2 — shared cursors (relay ⇄ WebRTC direct)</h3>
|
|
25
25
|
<div id="bar">
|
|
26
26
|
<span id="who">connecting…</span>
|
|
27
|
+
<span id="online"></span>
|
|
27
28
|
<button id="direct">Go direct</button>
|
|
28
29
|
<button id="relay">Back to relay</button>
|
|
29
30
|
<span id="route"></span>
|
|
30
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>
|
|
31
38
|
<canvas id="canvas" width="640" height="360"></canvas>
|
|
32
39
|
<div id="bar">
|
|
33
40
|
<button id="cam">📷 camera</button>
|
package/demo/server.ts
CHANGED
|
@@ -5,55 +5,93 @@ 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 {
|
|
9
|
-
import {createPeerHost} from '../src/Common/peer/peer-index'
|
|
8
|
+
import {SignalEnvelope} from '../src/Common/events/route-signal-webrtc'
|
|
9
|
+
import {createMediaRelay, createPeerHost} from '../src/Common/peer/peer-index'
|
|
10
10
|
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
11
11
|
|
|
12
12
|
const PORT = Number(process.env.PORT ?? 8390)
|
|
13
13
|
|
|
14
|
-
// ==============
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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>()
|
|
20
21
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
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
|
|
29
62
|
}
|
|
30
|
-
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
36
70
|
}
|
|
37
|
-
return lines
|
|
38
71
|
}
|
|
72
|
+
|
|
39
73
|
return {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
},
|
|
74
|
+
authorize,
|
|
75
|
+
canWatch: (watcher: string, owner: string) => grants.has(watcher + '|' + owner),
|
|
76
|
+
dropAccount,
|
|
52
77
|
}
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
const
|
|
56
|
-
const
|
|
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
|
+
|
|
57
95
|
const app = express()
|
|
58
96
|
app.use(express.static(path.resolve(__dirname, 'public')))
|
|
59
97
|
app.get('/', (_req, res) => res.sendFile(path.resolve(__dirname, 'public', 'index.html')))
|
|
@@ -64,7 +102,6 @@ const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
|
64
102
|
|
|
65
103
|
ioServer.on('connection', function onDemoConnection(socket) {
|
|
66
104
|
const account = String(socket.handshake.auth?.account ?? 'anon')
|
|
67
|
-
const watch = String(socket.handshake.auth?.watch ?? (account == 'a' ? 'b' : 'a'))
|
|
68
105
|
const peer = host.connection(account)
|
|
69
106
|
const [disconnect, disconnectListen] = listen<[]>()
|
|
70
107
|
socket.on('disconnect', () => { disconnect(); peer.close() })
|
|
@@ -76,18 +113,18 @@ ioServer.on('connection', function onDemoConnection(socket) {
|
|
|
76
113
|
serverTime: () => new Date().toISOString(),
|
|
77
114
|
peer: peer.fragment,
|
|
78
115
|
media: {
|
|
79
|
-
publish:
|
|
80
|
-
//
|
|
81
|
-
|
|
116
|
+
publish: media.publishOf(account),
|
|
117
|
+
// policy-gated view: THIS connection's account is what canWatch receives
|
|
118
|
+
watch: media.watchOf(account),
|
|
82
119
|
},
|
|
83
120
|
},
|
|
84
121
|
disconnectListen,
|
|
85
122
|
})
|
|
86
|
-
console.log(`[demo] ${account} connected
|
|
123
|
+
console.log(`[demo] ${account} connected`)
|
|
87
124
|
})
|
|
88
125
|
|
|
89
126
|
httpServer.listen(PORT, function onDemoListen() {
|
|
90
|
-
console.log('[demo] shared-cursor stand is up:')
|
|
127
|
+
console.log('[demo] shared-cursor + calls stand is up:')
|
|
91
128
|
console.log(` tab A: http://localhost:${PORT}/?me=a&peer=b`)
|
|
92
129
|
console.log(` tab B: http://localhost:${PORT}/?me=b&peer=a`)
|
|
93
130
|
})
|
package/doc/INTENT.md
CHANGED
|
@@ -1,31 +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/`.
|
|
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/`.
|