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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createMediaRelay = createMediaRelay;
|
|
4
|
+
const replay_listen_1 = require("../events/replay-listen");
|
|
5
|
+
const rpc_dynamic_1 = require("../rcp/rpc-dynamic");
|
|
6
|
+
function createMediaRelay(deps) {
|
|
7
|
+
const { lines, videoHistory = 8, audioHistory = 64, canWatch } = deps;
|
|
8
|
+
const accounts = new Map();
|
|
9
|
+
const watch = (0, rpc_dynamic_1.noStrict)({});
|
|
10
|
+
function makeLine(kind) {
|
|
11
|
+
return kind == 'video'
|
|
12
|
+
? (0, replay_listen_1.replayListen)({
|
|
13
|
+
history: videoHistory, current: 'last',
|
|
14
|
+
frame: function lastFrameOnly(tail) { return tail.length ? [tail[tail.length - 1]] : []; },
|
|
15
|
+
})
|
|
16
|
+
: (0, replay_listen_1.replayListen)({ history: audioHistory });
|
|
17
|
+
}
|
|
18
|
+
function linesOf(account) {
|
|
19
|
+
let entry = accounts.get(account);
|
|
20
|
+
if (!entry) {
|
|
21
|
+
entry = { emits: {}, view: (0, rpc_dynamic_1.noStrict)({}) };
|
|
22
|
+
for (const name of Object.keys(lines)) {
|
|
23
|
+
const [emit, api] = makeLine(lines[name]);
|
|
24
|
+
entry.emits[name] = emit;
|
|
25
|
+
entry.view[name] = api;
|
|
26
|
+
}
|
|
27
|
+
accounts.set(account, entry);
|
|
28
|
+
watch[account] = entry.view;
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
function publishOf(account) {
|
|
33
|
+
linesOf(account);
|
|
34
|
+
return function publish(line, frame, sentAt) {
|
|
35
|
+
linesOf(account).emits[line]?.(frame, sentAt);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const watcherViews = new Map();
|
|
39
|
+
function allowed(watcher, owner, line) {
|
|
40
|
+
return canWatch ? canWatch(watcher, owner, line) : true;
|
|
41
|
+
}
|
|
42
|
+
function filteredLine(watcher, owner, name, source, cache) {
|
|
43
|
+
const [emit, filtered] = (0, replay_listen_1.replayListen)({
|
|
44
|
+
history: lines[name] == 'video' ? videoHistory : audioHistory,
|
|
45
|
+
current: function currentIfAllowed() {
|
|
46
|
+
return allowed(watcher, owner, name) ? source.keyframe()?.event : undefined;
|
|
47
|
+
},
|
|
48
|
+
...(lines[name] == 'video' ? {
|
|
49
|
+
frame: function lastAllowedFrame(tail) {
|
|
50
|
+
return tail.length ? [tail[tail.length - 1]] : [];
|
|
51
|
+
},
|
|
52
|
+
} : {}),
|
|
53
|
+
});
|
|
54
|
+
const offSource = source.on(function forwardAllowed(frame, sentAt) {
|
|
55
|
+
if (allowed(watcher, owner, name))
|
|
56
|
+
emit(frame, sentAt);
|
|
57
|
+
});
|
|
58
|
+
const closeFiltered = filtered.close;
|
|
59
|
+
filtered.close = function closePolicyLine() {
|
|
60
|
+
offSource();
|
|
61
|
+
closeFiltered();
|
|
62
|
+
};
|
|
63
|
+
cache.filtered.add(filtered);
|
|
64
|
+
return filtered;
|
|
65
|
+
}
|
|
66
|
+
function ownerViewFor(watcher, owner, cache) {
|
|
67
|
+
let view = cache.owners.get(owner);
|
|
68
|
+
if (view)
|
|
69
|
+
return view;
|
|
70
|
+
const entry = accounts.get(owner);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return undefined;
|
|
73
|
+
const policyLines = {};
|
|
74
|
+
for (const name of Object.keys(lines))
|
|
75
|
+
policyLines[name] = filteredLine(watcher, owner, name, entry.view[name], cache);
|
|
76
|
+
view = (0, rpc_dynamic_1.noStrict)(new Proxy(policyLines, {
|
|
77
|
+
has(t, k) { return typeof k == 'string' && k in t && allowed(watcher, owner, k); },
|
|
78
|
+
get(t, k) {
|
|
79
|
+
if (typeof k != 'string')
|
|
80
|
+
return t[k];
|
|
81
|
+
return k in t && allowed(watcher, owner, k) ? t[k] : undefined;
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
cache.owners.set(owner, view);
|
|
85
|
+
return view;
|
|
86
|
+
}
|
|
87
|
+
function watchOf(watcher) {
|
|
88
|
+
let cached = watcherViews.get(watcher);
|
|
89
|
+
if (cached)
|
|
90
|
+
return cached.root;
|
|
91
|
+
const owners = new Map();
|
|
92
|
+
const filtered = new Set();
|
|
93
|
+
function visible(owner) {
|
|
94
|
+
return accounts.has(owner) && Object.keys(lines).some(name => allowed(watcher, owner, name));
|
|
95
|
+
}
|
|
96
|
+
const root = (0, rpc_dynamic_1.noStrict)(new Proxy({}, {
|
|
97
|
+
has(_t, k) { return typeof k == 'string' && visible(k); },
|
|
98
|
+
get(_t, k) {
|
|
99
|
+
if (typeof k != 'string')
|
|
100
|
+
return undefined;
|
|
101
|
+
return visible(k) ? ownerViewFor(watcher, k, cached) : undefined;
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
cached = { root, owners, filtered };
|
|
105
|
+
watcherViews.set(watcher, cached);
|
|
106
|
+
return root;
|
|
107
|
+
}
|
|
108
|
+
function dropLines(entry) {
|
|
109
|
+
for (const name of Object.keys(entry.view))
|
|
110
|
+
entry.view[name].close();
|
|
111
|
+
}
|
|
112
|
+
function closeWatcherCache(cache) {
|
|
113
|
+
for (const line of cache.filtered)
|
|
114
|
+
line.close();
|
|
115
|
+
cache.filtered.clear();
|
|
116
|
+
cache.owners.clear();
|
|
117
|
+
}
|
|
118
|
+
function dropAccount(account) {
|
|
119
|
+
const entry = accounts.get(account);
|
|
120
|
+
const ownCache = watcherViews.get(account);
|
|
121
|
+
if (ownCache)
|
|
122
|
+
closeWatcherCache(ownCache);
|
|
123
|
+
watcherViews.delete(account);
|
|
124
|
+
for (const cache of watcherViews.values()) {
|
|
125
|
+
const ownerView = cache.owners.get(account);
|
|
126
|
+
if (ownerView)
|
|
127
|
+
for (const line of Object.values(ownerView)) {
|
|
128
|
+
line.close();
|
|
129
|
+
cache.filtered.delete(line);
|
|
130
|
+
}
|
|
131
|
+
cache.owners.delete(account);
|
|
132
|
+
}
|
|
133
|
+
if (!entry)
|
|
134
|
+
return;
|
|
135
|
+
accounts.delete(account);
|
|
136
|
+
delete watch[account];
|
|
137
|
+
dropLines(entry);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
publishOf,
|
|
141
|
+
watch,
|
|
142
|
+
watchOf,
|
|
143
|
+
lines: (account) => linesOf(account).view,
|
|
144
|
+
accounts: () => Array.from(accounts.keys()),
|
|
145
|
+
dropAccount,
|
|
146
|
+
close() {
|
|
147
|
+
for (const cache of watcherViews.values())
|
|
148
|
+
closeWatcherCache(cache);
|
|
149
|
+
for (const entry of accounts.values())
|
|
150
|
+
dropLines(entry);
|
|
151
|
+
accounts.clear();
|
|
152
|
+
watcherViews.clear();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wenay-common2",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.74",
|
|
4
4
|
"description": "Common library",
|
|
5
5
|
"strict": true,
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,8 +42,5 @@
|
|
|
42
42
|
"./package.json": "./package.json",
|
|
43
43
|
"./media": "./lib/Common/media/media-index.js",
|
|
44
44
|
"./peer": "./lib/Common/peer/peer-index.js"
|
|
45
|
-
},
|
|
46
|
-
"dependencies": {
|
|
47
|
-
"express": "^5.2.1"
|
|
48
45
|
}
|
|
49
46
|
}
|
|
@@ -69,10 +69,10 @@ async function main() {
|
|
|
69
69
|
player.enable()
|
|
70
70
|
ok(player.enabled && ctx.resumed == 1, 'enable() builds the injected context and resumes it')
|
|
71
71
|
|
|
72
|
-
emit(pcmFrame(2), Date.now() -
|
|
72
|
+
emit(pcmFrame(2), Date.now() - 100)
|
|
73
73
|
ok(player.stats().played == 1 && ctx.scheduled.length == 1, 'enabled player schedules a decoded pcm16 buffer')
|
|
74
74
|
ok(ctx.scheduled[0].frames == 480, 'sample count survives decode (480 frames)')
|
|
75
|
-
ok(player.stats().ageMs >=
|
|
75
|
+
ok(player.stats().ageMs >= 80, 'sentAt stamp turns into a real age measurement')
|
|
76
76
|
|
|
77
77
|
const before = ctx.scheduled.length
|
|
78
78
|
emit(videoFrame(3, 320, 240), Date.now())
|
|
@@ -106,11 +106,11 @@ async function main() {
|
|
|
106
106
|
},
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
-
emit(videoFrame(1, 640, 480), Date.now() -
|
|
109
|
+
emit(videoFrame(1, 640, 480), Date.now() - 100)
|
|
110
110
|
await delay(10)
|
|
111
111
|
ok(view.stats().drawn == 1 && drawnImages.length == 1, 'video frame decoded via injected bitmap factory and drawn')
|
|
112
112
|
ok(canvas.width == 640 && canvas.height == 480, 'canvas takes the frame size')
|
|
113
|
-
ok(view.stats().width == 640 && view.stats().ageMs >=
|
|
113
|
+
ok(view.stats().width == 640 && view.stats().ageMs >= 80, 'stats expose frame size and age')
|
|
114
114
|
|
|
115
115
|
emit(pcmFrame(2), Date.now())
|
|
116
116
|
await delay(10)
|
|
@@ -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/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`.
|