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.
Files changed (39) hide show
  1. package/README.md +5 -3
  2. package/demo/client.ts +230 -3
  3. package/demo/index.html +37 -0
  4. package/demo/server.ts +91 -4
  5. package/doc/INTENT.md +31 -0
  6. package/doc/ROADMAP.md +233 -212
  7. package/doc/changes/1.0.71.md +9 -0
  8. package/doc/changes/1.0.72.md +6 -0
  9. package/doc/changes/1.0.73.md +6 -0
  10. package/doc/changes/1.0.74.md +12 -0
  11. package/doc/wenay-common2-rare.md +684 -627
  12. package/doc/wenay-common2.md +50 -5
  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/media/media-index.d.ts +1 -0
  16. package/lib/Common/media/media-index.js +1 -0
  17. package/lib/Common/media/media-source.d.ts +3 -0
  18. package/lib/Common/media/media-source.js +136 -27
  19. package/lib/Common/media/media-view.d.ts +42 -0
  20. package/lib/Common/media/media-view.js +210 -0
  21. package/lib/Common/peer/peer-call.d.ts +65 -0
  22. package/lib/Common/peer/peer-call.js +173 -0
  23. package/lib/Common/peer/peer-client.d.ts +21 -5
  24. package/lib/Common/peer/peer-client.js +53 -3
  25. package/lib/Common/peer/peer-host.d.ts +22 -5
  26. package/lib/Common/peer/peer-host.js +49 -5
  27. package/lib/Common/peer/peer-index.d.ts +2 -0
  28. package/lib/Common/peer/peer-index.js +2 -0
  29. package/lib/Common/peer/peer-media-relay.d.ts +22 -0
  30. package/lib/Common/peer/peer-media-relay.js +155 -0
  31. package/lib/Common/peer/peer-relay.d.ts +10 -2
  32. package/lib/Common/peer/peer-relay.js +40 -18
  33. package/observe/listen-core.test.ts +1 -2
  34. package/package.json +1 -4
  35. package/replay/media-view.test.ts +164 -0
  36. package/replay/peer-call.test.ts +226 -0
  37. package/replay/peer-repair.test.ts +184 -0
  38. package/doc/changes/1.0.63.md +0 -8
  39. 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 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.
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
- - 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
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} · watching: ${other}`
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 {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'
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
- const host = createPeerHost()
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
- const ioServer = new SocketIOServer(httpServer)
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/`.