wenay-common2 1.0.73 → 1.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +3 -2
  2. package/demo/client.ts +137 -32
  3. package/demo/index.html +7 -0
  4. package/demo/server.ts +79 -42
  5. package/doc/INTENT.md +31 -31
  6. package/doc/ROADMAP.md +233 -230
  7. package/doc/changes/1.0.74.md +12 -0
  8. package/doc/changes/1.0.75.md +10 -0
  9. package/doc/wenay-common2-rare.md +705 -649
  10. package/doc/wenay-common2.md +438 -396
  11. package/lib/Common/events/replay-route.js +5 -2
  12. package/lib/Common/events/replay-wire.js +137 -47
  13. package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
  14. package/lib/Common/events/route-signal-webrtc.js +15 -5
  15. package/lib/Common/events/transport-lifecycle.d.ts +23 -0
  16. package/lib/Common/events/transport-lifecycle.js +94 -0
  17. package/lib/Common/media/media-view.d.ts +2 -2
  18. package/lib/Common/media/media-view.js +23 -4
  19. package/lib/Common/peer/peer-call.d.ts +65 -0
  20. package/lib/Common/peer/peer-call.js +173 -0
  21. package/lib/Common/peer/peer-client.d.ts +9 -0
  22. package/lib/Common/peer/peer-host.d.ts +14 -1
  23. package/lib/Common/peer/peer-host.js +48 -4
  24. package/lib/Common/peer/peer-index.d.ts +2 -0
  25. package/lib/Common/peer/peer-index.js +2 -0
  26. package/lib/Common/peer/peer-media-relay.d.ts +22 -0
  27. package/lib/Common/peer/peer-media-relay.js +155 -0
  28. package/lib/Common/rcp/rpc-client.js +346 -111
  29. package/lib/Common/rcp/rpc-clientHub.d.ts +2 -0
  30. package/lib/Common/rcp/rpc-clientHub.js +120 -25
  31. package/lib/Common/rcp/rpc-server.js +33 -9
  32. package/observe/listen-core.test.ts +1 -2
  33. package/oracle/realsocket/lifecycle.spec.ts +3 -3
  34. package/oracle/realsocket/replay-reconnect-auth.spec.ts +111 -0
  35. package/oracle/realsocket/replay-reconnect.spec.ts +592 -0
  36. package/package.json +1 -4
  37. package/replay/media-view.test.ts +4 -4
  38. package/replay/peer-call.test.ts +226 -0
  39. package/rpc.md +19 -4
  40. package/doc/changes/1.0.64.md +0 -10
  41. package/doc/changes/1.0.65.md +0 -8
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 stand (`npm run demo`): shared cursors in two browser tabs over the
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
- - Note: examples import from the repo's `src/`; in the installed package the same API comes from
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 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
+ // 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} · watching: ${other}`
31
+ el('who').textContent = `me: ${me} · peer: ${other}`
31
32
 
32
33
  const hub = createRpcClientHub(
33
- () => io({transports: ['websocket'], auth: {account: me, watch: other}}),
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
- setupMedia(clients.app.func.media)
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, watch the peer's ==============
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 demo relay (fire-and-forget) --------
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
- // -------- 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')
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 (player.enabled) {
187
- player.disable()
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
- 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`
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 {replayListen} from '../src/Common/events/replay-listen'
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
- // ============== 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'
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 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}),
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
- 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)
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
- // 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
- },
74
+ authorize,
75
+ canWatch: (watcher: string, owner: string) => grants.has(watcher + '|' + owner),
76
+ dropAccount,
52
77
  }
53
78
  }
54
79
 
55
- const host = createPeerHost()
56
- const mediaHub = createDemoMediaHub()
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: mediaHub.publishOf(account),
80
- // the watched account's live lines, declared at connect time (auth.watch)
81
- peer: mediaHub.watchOf(watch),
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 (watching ${watch})`)
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/`.