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.
- 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/changes/1.0.75.md +10 -0
- package/doc/wenay-common2-rare.md +705 -649
- package/doc/wenay-common2.md +438 -396
- package/lib/Common/events/replay-route.js +5 -2
- package/lib/Common/events/replay-wire.js +137 -47
- package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
- package/lib/Common/events/route-signal-webrtc.js +15 -5
- package/lib/Common/events/transport-lifecycle.d.ts +23 -0
- package/lib/Common/events/transport-lifecycle.js +94 -0
- 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/lib/Common/rcp/rpc-client.js +346 -111
- package/lib/Common/rcp/rpc-clientHub.d.ts +2 -0
- package/lib/Common/rcp/rpc-clientHub.js +120 -25
- package/lib/Common/rcp/rpc-server.js +33 -9
- package/observe/listen-core.test.ts +1 -2
- package/oracle/realsocket/lifecycle.spec.ts +3 -3
- package/oracle/realsocket/replay-reconnect-auth.spec.ts +111 -0
- package/oracle/realsocket/replay-reconnect.spec.ts +592 -0
- package/package.json +1 -4
- package/replay/media-view.test.ts +4 -4
- package/replay/peer-call.test.ts +226 -0
- package/rpc.md +19 -4
- package/doc/changes/1.0.64.md +0 -10
- package/doc/changes/1.0.65.md +0 -8
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Acceptance oracle for the call system (ROADMAP "Call system" candidate):
|
|
2
|
+
// presence + ring/accept/decline/hangup over the EXISTING signal hub, media
|
|
3
|
+
// frames over the relay lines while the call is active — all on a real
|
|
4
|
+
// Socket.IO/RPC wire, next to the peer fragment, with zero new server routes
|
|
5
|
+
// beyond the fragment keys (presence) and the media relay object.
|
|
6
|
+
import express from 'express'
|
|
7
|
+
import {createServer} from 'http'
|
|
8
|
+
import type {AddressInfo} from 'net'
|
|
9
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
10
|
+
import {io} from 'socket.io-client'
|
|
11
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
12
|
+
import {createSignalHub, SignalEnvelope} from '../src/Common/events/route-signal-webrtc'
|
|
13
|
+
import {createPeerHost} from '../src/Common/peer/peer-host'
|
|
14
|
+
import {callPortOf, createCallManager} from '../src/Common/peer/peer-call'
|
|
15
|
+
import {createMediaRelay} from '../src/Common/peer/peer-media-relay'
|
|
16
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
17
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
18
|
+
|
|
19
|
+
let fails = 0
|
|
20
|
+
const ok = (condition: any, message: string) => {
|
|
21
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
22
|
+
else console.log(' OK ', message)
|
|
23
|
+
}
|
|
24
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
25
|
+
|
|
26
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
27
|
+
for (let i = 0; i < 200; i++) {
|
|
28
|
+
if (cond()) return
|
|
29
|
+
await delay(20)
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`timeout: ${label}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
console.log('\n[peer-call] presence + ring/accept/decline + media over the relay')
|
|
36
|
+
|
|
37
|
+
// ================= lifecycle regressions (in-process, deterministic) =================
|
|
38
|
+
const signalHub = createSignalHub()
|
|
39
|
+
const firstA = signalHub.register('same')
|
|
40
|
+
const newestA = signalHub.register('same')
|
|
41
|
+
const sender = signalHub.register('sender')
|
|
42
|
+
let firstHits = 0
|
|
43
|
+
let newestHits = 0
|
|
44
|
+
firstA.signals.on(() => firstHits++)
|
|
45
|
+
newestA.signals.on(() => newestHits++)
|
|
46
|
+
await sender.send({type: 'ring', pair: 'multi:1', from: 'sender', to: 'same'})
|
|
47
|
+
newestA.close()
|
|
48
|
+
const fallbackSent = await sender.send({type: 'ring', pair: 'multi:2', from: 'sender', to: 'same'})
|
|
49
|
+
ok(newestHits == 1 && firstHits == 1 && fallbackSent == true,
|
|
50
|
+
'closing the newest same-account signal port restores the previous live port')
|
|
51
|
+
signalHub.close()
|
|
52
|
+
|
|
53
|
+
const [emitLocalSignal, localSignals] = createListenPair<[SignalEnvelope]>()
|
|
54
|
+
const localSends: SignalEnvelope[] = []
|
|
55
|
+
const localCalls = createCallManager({
|
|
56
|
+
self: 'callee',
|
|
57
|
+
port: {
|
|
58
|
+
signals: localSignals,
|
|
59
|
+
send: function captureLocalSend(env) { localSends.push(env); return true },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
let localRings = 0
|
|
63
|
+
localCalls.rings.on(() => localRings++)
|
|
64
|
+
emitLocalSignal({type: 'ring', pair: 'call:x:1', from: 'x', to: 'callee'})
|
|
65
|
+
emitLocalSignal({type: 'ring', pair: 'call:y:1', from: 'y', to: 'callee'})
|
|
66
|
+
await delay(0)
|
|
67
|
+
ok(localRings == 1 && localSends.some(env => env.type == 'decline' && env.reason == 'busy'),
|
|
68
|
+
'back-to-back incoming rings reserve admission: one rings, one is busy')
|
|
69
|
+
localCalls.close()
|
|
70
|
+
|
|
71
|
+
// ================= SERVER: peer fragment + media relay, no call-specific code =================
|
|
72
|
+
// Watch ACL as APP code: media access follows call state — the policy set is
|
|
73
|
+
// maintained by the clients below on 'active'/'ended' (via a legacy-style rpc key).
|
|
74
|
+
const allowedWatch = new Set<string>()
|
|
75
|
+
const host = createPeerHost()
|
|
76
|
+
const media = createMediaRelay({
|
|
77
|
+
lines: {cam: 'video', mic: 'audio'},
|
|
78
|
+
canWatch: (watcher, owner) => allowedWatch.has(watcher + '|' + owner),
|
|
79
|
+
})
|
|
80
|
+
const app = express()
|
|
81
|
+
const httpServer = createServer(app)
|
|
82
|
+
const ioServer = new SocketIOServer(httpServer)
|
|
83
|
+
ioServer.on('connection', socket => {
|
|
84
|
+
const account = String(socket.handshake.auth?.account)
|
|
85
|
+
const peer = host.connection(account)
|
|
86
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
87
|
+
socket.on('disconnect', () => { disconnect(); peer.close() })
|
|
88
|
+
createRpcServerAuto({
|
|
89
|
+
socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
|
|
90
|
+
socketKey: 'app',
|
|
91
|
+
object: {
|
|
92
|
+
peer: peer.fragment,
|
|
93
|
+
media: {publish: media.publishOf(account), watch: media.watchOf(account)},
|
|
94
|
+
// app-level grant hook (a real app would derive this from its own call registry)
|
|
95
|
+
grantWatch: (a: string, b: string, on: boolean) => {
|
|
96
|
+
if (on) { allowedWatch.add(a + '|' + b); allowedWatch.add(b + '|' + a) }
|
|
97
|
+
else { allowedWatch.delete(a + '|' + b); allowedWatch.delete(b + '|' + a) }
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
disconnectListen,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
httpServer.once('error', reject)
|
|
105
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
106
|
+
})
|
|
107
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
108
|
+
|
|
109
|
+
async function connect(account: string) {
|
|
110
|
+
const hub = createRpcClientHub(
|
|
111
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true, auth: {account}}),
|
|
112
|
+
r => ({api: r<any>('app')}) as const,
|
|
113
|
+
)
|
|
114
|
+
const clients = await hub.setToken(null)
|
|
115
|
+
await clients.api.readyStrict()
|
|
116
|
+
return {func: clients.api.func, closeSocket: () => hub.socket?.disconnect?.()}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const connA = await connect('a')
|
|
120
|
+
const connB = await connect('b')
|
|
121
|
+
|
|
122
|
+
// ================= presence =================
|
|
123
|
+
const presenceLog: any[] = []
|
|
124
|
+
connA.func.peer.presence.changes.on((ch: any) => presenceLog.push(ch))
|
|
125
|
+
const online = await connA.func.peer.presence.list()
|
|
126
|
+
ok(online.includes('a') && online.includes('b'), `presence list sees both accounts: ${JSON.stringify(online)}`)
|
|
127
|
+
|
|
128
|
+
// ================= happy path: ring -> accept -> active =================
|
|
129
|
+
const callsA = createCallManager({port: callPortOf(connA.func.peer), self: 'a'})
|
|
130
|
+
const callsB = createCallManager({port: callPortOf(connB.func.peer), self: 'b'})
|
|
131
|
+
await Promise.all([callsA.ready, callsB.ready])
|
|
132
|
+
let ringAtB: any = null
|
|
133
|
+
callsB.rings.on((h: any) => { ringAtB = h })
|
|
134
|
+
|
|
135
|
+
const out = callsA.call('b', {kinds: ['cam', 'mic']})
|
|
136
|
+
await waitFor('ring reaches b', () => ringAtB != null)
|
|
137
|
+
ok(ringAtB.peer == 'a' && ringAtB.direction == 'in', 'incoming handle points back at the caller')
|
|
138
|
+
ok(JSON.stringify(ringAtB.meta) == JSON.stringify({kinds: ['cam', 'mic']}), 'caller meta rides the ring envelope opaquely')
|
|
139
|
+
ok(out.state() == 'ringing' && ringAtB.state() == 'ringing', 'both sides are ringing')
|
|
140
|
+
|
|
141
|
+
// ================= watch ACL: denied OUTSIDE a call =================
|
|
142
|
+
let deniedBefore = false
|
|
143
|
+
try { await connB.func.media.watch.a.cam.keyframe() } catch { deniedBefore = true }
|
|
144
|
+
ok(deniedBefore, 'watch is DENIED outside a call (canWatch gates path resolution)')
|
|
145
|
+
|
|
146
|
+
ringAtB.accept()
|
|
147
|
+
await waitFor('caller sees accept', () => out.state() == 'active')
|
|
148
|
+
ok(callsB.active() == ringAtB && callsA.active() == out, 'both sides report the active call')
|
|
149
|
+
|
|
150
|
+
// ================= media while active: grant -> publish -> relay line -> watcher =================
|
|
151
|
+
await connA.func.grantWatch('a', 'b', true) // app code: access follows the call
|
|
152
|
+
const got: any[] = []
|
|
153
|
+
connB.func.media.watch.a.cam.on((frame: any, sentAt: number) => got.push([frame, sentAt]))
|
|
154
|
+
await connA.func.media.publish('cam', new Uint8Array([1, 2, 3]), 12345)
|
|
155
|
+
await waitFor('cam frame lands at b', () => got.length > 0)
|
|
156
|
+
ok(got[0][1] == 12345, 'sentAt stamp rides along the media frame')
|
|
157
|
+
const kf = await connB.func.media.watch.a.cam.keyframe()
|
|
158
|
+
ok(kf != null && kf.event[1] == 12345, 'video line is keep-latest: a late joiner pulls the last frame via keyframe()')
|
|
159
|
+
|
|
160
|
+
// ================= busy gate: third account calls into an active call =================
|
|
161
|
+
const connC = await connect('c')
|
|
162
|
+
const callsC = createCallManager({port: callPortOf(connC.func.peer), self: 'c'})
|
|
163
|
+
await callsC.ready
|
|
164
|
+
const cToB = callsC.call('b')
|
|
165
|
+
ok(await cToB.ended == 'busy', 'default gate auto-declines with busy while b is on a call')
|
|
166
|
+
let deniedThird = false
|
|
167
|
+
try { await connC.func.media.watch.a.cam.keyframe() } catch { deniedThird = true }
|
|
168
|
+
ok(deniedThird, 'a third account is still denied while a<->b are on a call')
|
|
169
|
+
|
|
170
|
+
// ================= hangup: media access is revoked with the call =================
|
|
171
|
+
out.hangup()
|
|
172
|
+
await waitFor('callee sees hangup', () => ringAtB.state() == 'ended')
|
|
173
|
+
ok(ringAtB.reason() == 'hangup' && await out.ended == 'hangup', 'hangup ends both sides with the reason')
|
|
174
|
+
await connA.func.grantWatch('a', 'b', false) // app code: revoke with the call
|
|
175
|
+
const framesBeforeRevoke = got.length
|
|
176
|
+
await connA.func.media.publish('cam', new Uint8Array([9]), 54321)
|
|
177
|
+
await delay(50)
|
|
178
|
+
ok(got.length == framesBeforeRevoke, 'ACL revocation blocks frames on an already-open media subscription')
|
|
179
|
+
let deniedAfter = false
|
|
180
|
+
try { await connB.func.media.watch.a.cam.keyframe() } catch { deniedAfter = true }
|
|
181
|
+
ok(deniedAfter, 'watch is denied again after the call ends (new resolutions blocked)')
|
|
182
|
+
media.dropAccount('c')
|
|
183
|
+
ok(!media.accounts().includes('c') && media.accounts().includes('a'), 'dropAccount retires the account keyspace (server-side cleanup hook)')
|
|
184
|
+
|
|
185
|
+
// ================= decline =================
|
|
186
|
+
let ringAtA: any = null
|
|
187
|
+
callsA.rings.on((h: any) => { ringAtA = h })
|
|
188
|
+
const bToA = callsB.call('a')
|
|
189
|
+
await waitFor('ring reaches a', () => ringAtA != null)
|
|
190
|
+
ringAtA.decline()
|
|
191
|
+
ok(await bToA.ended == 'declined', 'decline propagates to the caller')
|
|
192
|
+
|
|
193
|
+
// ================= offline callee: fails fast, no timeout wait =================
|
|
194
|
+
const t0 = Date.now()
|
|
195
|
+
const toGhost = callsA.call('ghost')
|
|
196
|
+
ok(await toGhost.ended == 'offline' && Date.now() - t0 < 2000, 'calling an offline account fails fast with offline')
|
|
197
|
+
|
|
198
|
+
// ================= ring timeout =================
|
|
199
|
+
const callsA2 = createCallManager({port: callPortOf(connA.func.peer), self: 'a', ringTimeoutMs: 300})
|
|
200
|
+
await callsA2.ready
|
|
201
|
+
let unanswered: any = null
|
|
202
|
+
const offRings = callsB.rings.on((h: any) => { unanswered = h })
|
|
203
|
+
const timedOut = callsA2.call('b')
|
|
204
|
+
ok(await timedOut.ended == 'timeout', 'unanswered ring times out on the caller')
|
|
205
|
+
await waitFor('callee ring cleaned up', () => unanswered != null && unanswered.state() == 'ended')
|
|
206
|
+
ok(unanswered.reason() == 'canceled', 'the callee ring is canceled by the caller timeout')
|
|
207
|
+
if (typeof offRings == 'function') offRings()
|
|
208
|
+
|
|
209
|
+
// ================= presence offline edge =================
|
|
210
|
+
connC.closeSocket()
|
|
211
|
+
await waitFor('presence offline edge', () => presenceLog.some(ch => ch.account == 'c' && ch.online == false))
|
|
212
|
+
ok(true, 'presence emits the offline edge on disconnect')
|
|
213
|
+
|
|
214
|
+
// ================= teardown =================
|
|
215
|
+
callsA.close(); callsA2.close(); callsB.close(); callsC.close()
|
|
216
|
+
connA.closeSocket(); connB.closeSocket()
|
|
217
|
+
media.close()
|
|
218
|
+
host.close()
|
|
219
|
+
ioServer.close()
|
|
220
|
+
await new Promise<void>(resolve => httpServer.close(() => resolve()))
|
|
221
|
+
|
|
222
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
223
|
+
if (fails) process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
package/rpc.md
CHANGED
|
@@ -123,17 +123,32 @@ export const Api = createRpcClientHub(
|
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
### 3.2 Connection Lifecycle
|
|
126
|
+
|
|
127
|
+
`onConnect` and `onDisconnect` are legacy single-slot setters: each call replaces the previous callback, and passing `null` clears that slot. Use the additive listeners when several independent consumers need lifecycle events.
|
|
128
|
+
|
|
126
129
|
```typescript
|
|
127
|
-
//
|
|
130
|
+
// Legacy single-slot callbacks.
|
|
128
131
|
Api.onConnect((count) => console.log(`Socket connected (attempt ${count})`));
|
|
132
|
+
Api.onDisconnect((reason) => console.log(`Socket disconnected: ${reason}`));
|
|
133
|
+
|
|
134
|
+
// Additive listeners. Each off handle removes only its own callback.
|
|
135
|
+
const offConnect = Api.connectListen((count) => console.log(`Observer connected: ${count}`));
|
|
136
|
+
const offDisconnect = Api.disconnectListen((reason) => console.log(`Observer disconnected: ${reason}`));
|
|
129
137
|
|
|
130
|
-
//
|
|
138
|
+
// Hard connect/token generation. The promise resolves after the RPC handshake.
|
|
131
139
|
await Api.connect("USER_SECRET_TOKEN");
|
|
132
140
|
|
|
133
|
-
//
|
|
134
|
-
|
|
141
|
+
// Calls inside onConnect/connectListen are safe: those callbacks also run after the handshake.
|
|
142
|
+
offConnect();
|
|
143
|
+
offDisconnect();
|
|
135
144
|
```
|
|
136
145
|
|
|
146
|
+
A transient Socket.IO disconnect on the same socket suspends transport without ending logical Listen consumers. After automatic reconnect, the hub recreates one physical subscription for every still-active deduplicated Listen; consumers removed while offline are not restored.
|
|
147
|
+
|
|
148
|
+
Only logical Listen subscriptions are recovered. Pending or failed ordinary RPC calls and pipelines are not retried, because repeating them could duplicate side effects.
|
|
149
|
+
|
|
150
|
+
`client.dispose()` is terminal for that RPC client. `Api.setToken(token)` and its `Api.connect(token)` alias are hard rotations: the old socket/client generation and its subscriptions are permanently closed, and the new facade starts without inherited subscriptions. `connect(null)` starts a new anonymous generation; it is not a transient reconnect.
|
|
151
|
+
|
|
137
152
|
### 3.3 Call Modes
|
|
138
153
|
Access API channel: `Api.facade.mainAPI`. Hub initializes all channels, so schema loads automatically.
|
|
139
154
|
|
package/doc/changes/1.0.64.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# 1.0.64
|
|
2
|
-
|
|
3
|
-
- Make `README.md` navigation-only and point it to the brief docs, rare docs, naming migrations, recent changes, and project rules.
|
|
4
|
-
- Add the `doc/changes/` release-note catalog rule to `CLAUDE.md`: one file per published version, commit-style summary, latest 10 versions only.
|
|
5
|
-
- Add the recent changes catalog README and keep the current release notes under `doc/changes/`.
|
|
6
|
-
- Package documentation, `CLAUDE.md`, and `rpc.md` into `dist` so README links work in the npm package.
|
|
7
|
-
- Restore the `noStrict` dynamic-map contract in the extended docs after removing a duplicated store-manager block.
|
|
8
|
-
- Move regression oracles to the canonical `observe/reactive` names after the `observable2/reactive2` rename.
|
|
9
|
-
- Normalize npm repository metadata.
|
|
10
|
-
- Publish package `wenay-common2@1.0.64`.
|
package/doc/changes/1.0.65.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# 1.0.65
|
|
2
|
-
|
|
3
|
-
- Add `Replay.replayRouteSubscribe` for transport-agnostic replay route hand-off: keep the old route live, catch up the replacement route by `seq`, then close the old route.
|
|
4
|
-
- Add `Observe.syncStoreReplayRoute` for store mirrors that can switch relay/direct routes without gaps or duplicate patch delivery.
|
|
5
|
-
- Add `replay/route-handoff.test.ts` covering relay -> direct promotion, direct -> relay re-interposition, failed replacement fallback, and store mirror convergence.
|
|
6
|
-
- Update roadmap/replay docs to mark connection hand-off as partially implemented and keep signaling/WebRTC/policy triggers explicitly open.
|
|
7
|
-
- Normalize package metadata for `wenay-common2@1.0.65`.
|
|
8
|
-
- Add `doc/progress/` working-checkpoint rules for non-trivial tasks and keep task progress files out of package output.
|