wenay-common2 1.0.69 → 1.0.73
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 +11 -1
- package/demo/client.ts +222 -0
- package/demo/index.html +58 -0
- package/demo/server.ts +93 -0
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +19 -1
- package/doc/changes/1.0.70.md +4 -0
- package/doc/changes/1.0.71.md +9 -0
- package/doc/changes/1.0.72.md +6 -0
- package/doc/changes/1.0.73.md +6 -0
- package/doc/wenay-common2-rare.md +29 -8
- package/doc/wenay-common2.md +13 -2
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +1 -0
- package/lib/Common/media/media-source.d.ts +3 -0
- package/lib/Common/media/media-source.js +136 -27
- package/lib/Common/media/media-view.d.ts +42 -0
- package/lib/Common/media/media-view.js +191 -0
- package/lib/Common/peer/peer-client.d.ts +12 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +8 -4
- package/lib/Common/peer/peer-host.js +2 -2
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +6 -1
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/media-view.test.ts +164 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-repair.test.ts +184 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
- package/doc/changes/1.0.63.md +0 -8
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// Acceptance oracle for step 9 (webrtc-route-coordinator-tasks): signaling
|
|
2
|
+
// adapter (offer/answer/ICE/session/revoke) over the existing control channel +
|
|
3
|
+
// WebRTC direct connector under createRouteCoordinator. RTCPeerConnection is
|
|
4
|
+
// faked in-process (the runtime factory is injected by design); the second
|
|
5
|
+
// section drives the SAME signaling over a real Socket.IO/RPC wire.
|
|
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 {
|
|
13
|
+
acceptWebRtcDirect, createRouteCoordinator, createSignalHub, createWebRtcConnector,
|
|
14
|
+
exposeReplay, replayListen, RouteConnector, RtcDataChannel, RtcPeerConnection,
|
|
15
|
+
SignalPort, tConnectorState,
|
|
16
|
+
} from '../src/Common/events/replay-index'
|
|
17
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
18
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
19
|
+
|
|
20
|
+
let fails = 0
|
|
21
|
+
const ok = (condition: any, message: string) => {
|
|
22
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
23
|
+
else console.log(' OK ', message)
|
|
24
|
+
}
|
|
25
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
26
|
+
const json = (v: any) => JSON.stringify(v)
|
|
27
|
+
|
|
28
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
29
|
+
for (let i = 0; i < 200; i++) {
|
|
30
|
+
if (cond()) return
|
|
31
|
+
await delay(15)
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`timeout: ${label}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =====================================================================
|
|
37
|
+
// Fake in-process WebRTC runtime: SDP = pc id, ICE trickle simulated,
|
|
38
|
+
// datachannel = paired in-proc endpoints. Injected exactly like a browser
|
|
39
|
+
// would inject () => new RTCPeerConnection(cfg).
|
|
40
|
+
// =====================================================================
|
|
41
|
+
|
|
42
|
+
function createFakeRtcNet() {
|
|
43
|
+
let n = 0
|
|
44
|
+
const pcs = new Map<string, any>()
|
|
45
|
+
const stats = {ice: 0, channels: 0}
|
|
46
|
+
|
|
47
|
+
function makeDcPair(): [RtcDataChannel, RtcDataChannel] {
|
|
48
|
+
let closed = false
|
|
49
|
+
function side(other: () => any): any {
|
|
50
|
+
const me: any = {onopen: null, onmessage: null, onclose: null, onerror: null}
|
|
51
|
+
me.send = (data: string) => {
|
|
52
|
+
if (closed) return
|
|
53
|
+
setTimeout(function deliverDc() { if (!closed2()) other().onmessage?.({data}) }, 0)
|
|
54
|
+
}
|
|
55
|
+
me.close = () => {
|
|
56
|
+
if (closed) return
|
|
57
|
+
closed = true
|
|
58
|
+
setTimeout(function closeDc() { me.onclose?.(); other().onclose?.() }, 0)
|
|
59
|
+
}
|
|
60
|
+
return me
|
|
61
|
+
}
|
|
62
|
+
const closed2 = () => closed
|
|
63
|
+
let a: any, b: any
|
|
64
|
+
a = side(() => b)
|
|
65
|
+
b = side(() => a)
|
|
66
|
+
return [a, b]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tryConnect(me: any) {
|
|
70
|
+
if (!me.local || !me.remote) return
|
|
71
|
+
const other = pcs.get(me.remote.sdp)
|
|
72
|
+
if (!other || !other.local || !other.remote || other.remote.sdp != me.id) return
|
|
73
|
+
const initiator = me.pendingDcs.length ? me : other
|
|
74
|
+
const responder = initiator == me ? other : me
|
|
75
|
+
if (!initiator.pendingDcs.length || initiator.linked) return
|
|
76
|
+
initiator.linked = responder.linked = true
|
|
77
|
+
for (const dc of initiator.pendingDcs) {
|
|
78
|
+
const [a, b] = makeDcPair()
|
|
79
|
+
// связываем заранее созданный локальный конец с новорождённым твином
|
|
80
|
+
dc.attach(a)
|
|
81
|
+
stats.channels++
|
|
82
|
+
setTimeout(function announceChannel() {
|
|
83
|
+
responder.pc.ondatachannel?.({channel: b})
|
|
84
|
+
dc.fireOpen()
|
|
85
|
+
}, 0)
|
|
86
|
+
}
|
|
87
|
+
initiator.pendingDcs = []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pc(): RtcPeerConnection {
|
|
91
|
+
const id = 'sdp-' + (++n)
|
|
92
|
+
const me: any = {id, local: null, remote: null, pendingDcs: [], linked: false}
|
|
93
|
+
const api: RtcPeerConnection = {
|
|
94
|
+
createDataChannel() {
|
|
95
|
+
// локальный конец создаётся ДО соединения: методы делегируются после attach
|
|
96
|
+
let real: any = null
|
|
97
|
+
const shell: any = {onopen: null, onmessage: null, onclose: null, onerror: null}
|
|
98
|
+
shell.send = (data: string) => real?.send(data)
|
|
99
|
+
shell.close = () => real?.close()
|
|
100
|
+
me.pendingDcs.push({
|
|
101
|
+
attach(a: any) {
|
|
102
|
+
real = a
|
|
103
|
+
if (!a) return
|
|
104
|
+
a.onmessage = (ev: any) => shell.onmessage?.(ev)
|
|
105
|
+
a.onclose = () => shell.onclose?.()
|
|
106
|
+
},
|
|
107
|
+
fireOpen() { shell.onopen?.() },
|
|
108
|
+
})
|
|
109
|
+
return shell
|
|
110
|
+
},
|
|
111
|
+
createOffer: async () => ({type: 'offer', sdp: id}),
|
|
112
|
+
createAnswer: async () => ({type: 'answer', sdp: id}),
|
|
113
|
+
setLocalDescription(d) {
|
|
114
|
+
me.local = d
|
|
115
|
+
setTimeout(function trickleIce() { api.onicecandidate?.({candidate: {via: id}}) }, 0)
|
|
116
|
+
},
|
|
117
|
+
setRemoteDescription(d) {
|
|
118
|
+
me.remote = d
|
|
119
|
+
tryConnect(me)
|
|
120
|
+
},
|
|
121
|
+
addIceCandidate() { stats.ice++ },
|
|
122
|
+
close() {
|
|
123
|
+
for (const dc of me.pendingDcs) dc.attach?.(null)
|
|
124
|
+
me.pendingDcs = []
|
|
125
|
+
},
|
|
126
|
+
onicecandidate: null,
|
|
127
|
+
ondatachannel: null,
|
|
128
|
+
}
|
|
129
|
+
me.pc = api
|
|
130
|
+
pcs.set(id, me)
|
|
131
|
+
return api
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {pc, stats}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// простой relay-коннектор: прямой доступ к журналу с лагом (как в route-coordinator оракуле)
|
|
138
|
+
function makeRelayConnector<Z extends any[]>(replay: any, lag = 5): RouteConnector<Z> {
|
|
139
|
+
let state: tConnectorState = 'idle'
|
|
140
|
+
return {
|
|
141
|
+
info: {label: 'relay', kind: 'relay', ordered: true, reliable: true},
|
|
142
|
+
open: async () => {
|
|
143
|
+
state = 'opening'
|
|
144
|
+
await delay(lag)
|
|
145
|
+
state = 'open'
|
|
146
|
+
return {
|
|
147
|
+
line: {on: (cb: any) => replay.line.on(cb)},
|
|
148
|
+
since: async (s: number) => { await delay(lag); return replay.getSince(s) ?? null },
|
|
149
|
+
keyframe: async () => { await delay(lag); return replay.keyframe() ?? null },
|
|
150
|
+
frame: async (s: number, hint?: unknown) => { await delay(lag); return replay.frame(s, hint) },
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
close: () => { state = 'closed' },
|
|
154
|
+
state: () => state,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function main() {
|
|
159
|
+
console.log('\n[route-webrtc] in-proc signaling hub: offer/answer/ICE/session/revoke')
|
|
160
|
+
{
|
|
161
|
+
let state = 0
|
|
162
|
+
const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
|
|
163
|
+
const envLog: string[] = []
|
|
164
|
+
let allowDirect = true
|
|
165
|
+
const hub = createSignalHub({authorize: env => { envLog.push(env.type); return allowDirect }})
|
|
166
|
+
const portA = hub.register('a')
|
|
167
|
+
const portB = hub.register('b')
|
|
168
|
+
const rtcNet = createFakeRtcNet()
|
|
169
|
+
const sess = {token: 'good'}
|
|
170
|
+
const stopAccept = acceptWebRtcDirect<[number]>({
|
|
171
|
+
port: portB,
|
|
172
|
+
rtc: rtcNet.pc,
|
|
173
|
+
self: 'b',
|
|
174
|
+
serve: () => exposeReplay(replay),
|
|
175
|
+
accept: env => (env.session as any)?.token == 'good',
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const coord = createRouteCoordinator<[number]>({
|
|
179
|
+
connect: (ref, kind) => kind == 'relay'
|
|
180
|
+
? makeRelayConnector<[number]>(replay)
|
|
181
|
+
: createWebRtcConnector<[number]>({
|
|
182
|
+
port: portA, rtc: rtcNet.pc, self: 'a', peer: 'b',
|
|
183
|
+
pair: ref.key, session: sess, openTimeoutMs: 1500,
|
|
184
|
+
}),
|
|
185
|
+
})
|
|
186
|
+
const link = coord.pair('a', 'b')
|
|
187
|
+
const got: number[] = []
|
|
188
|
+
const sub = link.subscribe(v => got.push(v))
|
|
189
|
+
await sub.ready
|
|
190
|
+
ok(json(got) == json([0]), 'relay baseline delivers the keyframe')
|
|
191
|
+
|
|
192
|
+
state = 1; emit(state)
|
|
193
|
+
const res = await link.promoteDirect()
|
|
194
|
+
ok(res.ok && link.state() == 'direct', 'promotion over webrtc signaling lands in direct')
|
|
195
|
+
ok(envLog.includes('offer') && envLog.includes('answer') && envLog.includes('ice'), `hub authorized offer/answer/ice (${json(envLog)})`)
|
|
196
|
+
ok(rtcNet.stats.ice > 0 && rtcNet.stats.channels == 1, 'ICE exchanged and exactly one datachannel established')
|
|
197
|
+
state = 2; emit(state)
|
|
198
|
+
state = 3; emit(state)
|
|
199
|
+
await waitFor('datachannel live events', () => got.length >= 4)
|
|
200
|
+
ok(json(got) == json([0, 1, 2, 3]), 'replay wire over the datachannel is gap-free and dup-free')
|
|
201
|
+
|
|
202
|
+
// server-side revoke: policy изменилась — direct закрывается, relay продолжает с seq
|
|
203
|
+
hub.revoke(link.ref.key, ['a', 'b'], 'policy change')
|
|
204
|
+
await waitFor('revoke fallback', () => link.state() == 'fallback')
|
|
205
|
+
state = 4; emit(state)
|
|
206
|
+
await waitFor('relay after revoke', () => got.includes(4))
|
|
207
|
+
ok(json(got) == json([0, 1, 2, 3, 4]), 'server revoke: relay resumes from seq, no gap')
|
|
208
|
+
|
|
209
|
+
// сервер не раскрывает endpoint: offer отклонён ДО транспорта
|
|
210
|
+
allowDirect = false
|
|
211
|
+
const denied = await link.promoteDirect()
|
|
212
|
+
ok(!denied.ok && String(denied.reason).includes('endpoint'), 'hub authorize=false: offer rejected, direct not established')
|
|
213
|
+
ok(link.state() == 'fallback', 'denied endpoint leaves the pair on relay (fallback)')
|
|
214
|
+
|
|
215
|
+
// приёмная сторона бракует session-материал: revoke вместо таймаута
|
|
216
|
+
allowDirect = true
|
|
217
|
+
sess.token = 'bad'
|
|
218
|
+
const badSession = await link.promoteDirect()
|
|
219
|
+
ok(!badSession.ok && String(badSession.reason).includes('revoke'), `bad session material is rejected loudly by the peer (${String(badSession.reason)})`)
|
|
220
|
+
|
|
221
|
+
// и после всех отказов пара всё ещё может подняться в direct
|
|
222
|
+
sess.token = 'good'
|
|
223
|
+
const retry = await link.promoteDirect()
|
|
224
|
+
state = 5; emit(state)
|
|
225
|
+
await waitFor('direct again', () => got.includes(5))
|
|
226
|
+
ok(retry.ok && link.state() == 'direct' && json(got) == json([0, 1, 2, 3, 4, 5]), 'pair recovers into direct after denials, still gap-free')
|
|
227
|
+
|
|
228
|
+
coord.close()
|
|
229
|
+
stopAccept()
|
|
230
|
+
hub.close()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log('\n[route-webrtc] signaling over the EXISTING socket/RPC control channel')
|
|
234
|
+
{
|
|
235
|
+
let state = 0
|
|
236
|
+
const [emit, replay] = replayListen<[number]>({current: () => [state], history: 100})
|
|
237
|
+
const hub = createSignalHub()
|
|
238
|
+
const rtcNet = createFakeRtcNet()
|
|
239
|
+
|
|
240
|
+
// сервер: обычный createRpcServerAuto; сигнальный порт = {send, signals} на соединение
|
|
241
|
+
const app = express()
|
|
242
|
+
const httpServer = createServer(app)
|
|
243
|
+
const ioServer = new SocketIOServer(httpServer)
|
|
244
|
+
ioServer.on('connection', socket => {
|
|
245
|
+
const account = String(socket.handshake.auth?.account)
|
|
246
|
+
const port = hub.register(account)
|
|
247
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
248
|
+
socket.on('disconnect', () => { disconnect(); port.close() })
|
|
249
|
+
createRpcServerAuto({
|
|
250
|
+
socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
|
|
251
|
+
socketKey: 'signal',
|
|
252
|
+
object: {send: port.send, signals: port.signals},
|
|
253
|
+
disconnectListen,
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
await new Promise<void>((resolve, reject) => {
|
|
257
|
+
httpServer.once('error', reject)
|
|
258
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
259
|
+
})
|
|
260
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
261
|
+
|
|
262
|
+
async function connectSignalPort(account: string): Promise<SignalPort & {closeSocket: () => void}> {
|
|
263
|
+
const clientHub = createRpcClientHub(
|
|
264
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true, auth: {account}}),
|
|
265
|
+
r => ({api: r<any>('signal')}) as const,
|
|
266
|
+
)
|
|
267
|
+
const clients = await clientHub.setToken(null)
|
|
268
|
+
await clients.api.readyStrict()
|
|
269
|
+
const deep = clients.api.func
|
|
270
|
+
return {
|
|
271
|
+
send: (env: any) => deep.send(env),
|
|
272
|
+
signals: {on: (cb: any) => deep.signals.callback(cb)},
|
|
273
|
+
closeSocket: () => clientHub.socket?.disconnect?.(),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const portA = await connectSignalPort('a')
|
|
278
|
+
const portB = await connectSignalPort('b')
|
|
279
|
+
const stopAccept = acceptWebRtcDirect<[number]>({
|
|
280
|
+
port: portB, rtc: rtcNet.pc, self: 'b', serve: () => exposeReplay(replay),
|
|
281
|
+
})
|
|
282
|
+
const coord = createRouteCoordinator<[number]>({
|
|
283
|
+
connect: (ref, kind) => kind == 'relay'
|
|
284
|
+
? makeRelayConnector<[number]>(replay)
|
|
285
|
+
: createWebRtcConnector<[number]>({
|
|
286
|
+
port: portA, rtc: rtcNet.pc, self: 'a', peer: 'b',
|
|
287
|
+
pair: ref.key, openTimeoutMs: 4000,
|
|
288
|
+
}),
|
|
289
|
+
})
|
|
290
|
+
const link = coord.pair('a', 'b')
|
|
291
|
+
const got: number[] = []
|
|
292
|
+
const sub = link.subscribe(v => got.push(v))
|
|
293
|
+
await sub.ready
|
|
294
|
+
|
|
295
|
+
const res = await link.promoteDirect()
|
|
296
|
+
ok(res.ok && link.state() == 'direct', 'promotion negotiated over a real Socket.IO/RPC signaling channel')
|
|
297
|
+
state = 1; emit(state)
|
|
298
|
+
state = 2; emit(state)
|
|
299
|
+
await waitFor('live over datachannel (rpc-signaled)', () => got.length >= 3)
|
|
300
|
+
ok(json(got) == json([0, 1, 2]), 'payload rides the direct channel, signaling rode the rpc wire')
|
|
301
|
+
|
|
302
|
+
hub.revoke(link.ref.key, ['a', 'b'], 'server policy')
|
|
303
|
+
await waitFor('revoke over rpc wire', () => link.state() == 'fallback')
|
|
304
|
+
state = 3; emit(state)
|
|
305
|
+
await waitFor('relay resume (rpc-signaled)', () => got.includes(3))
|
|
306
|
+
ok(json(got) == json([0, 1, 2, 3]), 'revoke over the rpc wire falls back to relay with no gap')
|
|
307
|
+
|
|
308
|
+
coord.close()
|
|
309
|
+
stopAccept()
|
|
310
|
+
portA.closeSocket()
|
|
311
|
+
portB.closeSocket()
|
|
312
|
+
hub.close()
|
|
313
|
+
ioServer.close()
|
|
314
|
+
await new Promise<void>(resolve => httpServer.close(() => resolve()))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
318
|
+
if (fails) process.exit(1)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/rpc-auto.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Feature A+B (rev 2): replay-transparent RPC exposure.
|
|
5
|
+
// - легаси-подписка на replay-ключ = байт-в-байт plain Listen
|
|
6
|
+
// - replaySubscribe против merged-узла (since:0, no gaps/dups)
|
|
7
|
+
// - оба потребителя на ОДНОМ соединении
|
|
8
|
+
// - frame: мини-кадрик == fold полного хвоста (equivalence)
|
|
9
|
+
// - current:'last' — keyframe без ручного состояния
|
|
10
|
+
// - ворота лага (policy 'frame'): пропуск + восстановление кадром
|
|
11
|
+
// - негативная детекция: похожий объект НЕ replay
|
|
12
|
+
// - священная линия: frame бросает громко (in-proc)
|
|
13
|
+
// Запуск: npx ts-node replay/rpc-auto.test.ts
|
|
14
|
+
// ============================================================
|
|
15
|
+
|
|
16
|
+
import express from 'express'
|
|
17
|
+
import {createServer} from 'http'
|
|
18
|
+
import type {AddressInfo} from 'net'
|
|
19
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
20
|
+
import {io} from 'socket.io-client'
|
|
21
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
22
|
+
import {replayListen, ReplayEvent} from '../src/Common/events/replay-listen'
|
|
23
|
+
import {replaySubscribe, ReplayRemote} from '../src/Common/events/replay-wire'
|
|
24
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
25
|
+
import {createRpcServerAuto, RpcReplayOpts} from '../src/Common/rcp/rpc-server-auto'
|
|
26
|
+
import {Pkt} from '../src/Common/rcp/rpc-protocol'
|
|
27
|
+
import {rpcPathKey} from '../src/Common/rcp/rpc-path'
|
|
28
|
+
|
|
29
|
+
let fails = 0
|
|
30
|
+
const ok = (condition: any, message: string) => {
|
|
31
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
32
|
+
else console.log(' OK ', message)
|
|
33
|
+
}
|
|
34
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
35
|
+
const json = (v: any) => JSON.stringify(v)
|
|
36
|
+
|
|
37
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
38
|
+
for (let i = 0; i < 200; i++) {
|
|
39
|
+
if (cond()) return
|
|
40
|
+
await delay(25)
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`timeout: ${label}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function startRealServer(object: object, replayOpts?: RpcReplayOpts, captured?: {listenPaths?: string[]}) {
|
|
46
|
+
const app = express()
|
|
47
|
+
const httpServer = createServer(app)
|
|
48
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
49
|
+
ioServer.on('connection', socket => {
|
|
50
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
51
|
+
socket.on('disconnect', () => disconnect())
|
|
52
|
+
createRpcServerAuto({
|
|
53
|
+
socket: {
|
|
54
|
+
emit: (key, data) => {
|
|
55
|
+
// негативная детекция проверяется по серверной декларации Pkt.MAP
|
|
56
|
+
if (captured && Array.isArray(data) && data[0] === Pkt.MAP) captured.listenPaths = data[3]
|
|
57
|
+
socket.emit(key, data)
|
|
58
|
+
},
|
|
59
|
+
on: (key, cb) => socket.on(key, cb),
|
|
60
|
+
},
|
|
61
|
+
socketKey: 'rpc-auto',
|
|
62
|
+
object,
|
|
63
|
+
disconnectListen,
|
|
64
|
+
replayOpts,
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
httpServer.once('error', reject)
|
|
69
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
70
|
+
})
|
|
71
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
72
|
+
return {
|
|
73
|
+
port,
|
|
74
|
+
close: () => new Promise<void>(resolve => {
|
|
75
|
+
ioServer.close()
|
|
76
|
+
httpServer.close(() => resolve())
|
|
77
|
+
}),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startRealClient<T extends object>(port: number) {
|
|
82
|
+
const hub = createRpcClientHub(
|
|
83
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
|
|
84
|
+
r => ({api: r<T>('rpc-auto')}) as const,
|
|
85
|
+
)
|
|
86
|
+
const clients = await hub.setToken(null)
|
|
87
|
+
await clients.api.readyStrict()
|
|
88
|
+
return {
|
|
89
|
+
client: clients.api,
|
|
90
|
+
close: () => hub.socket?.disconnect?.(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// фасад: обычный Listen (baseline), одно-сущностная replay-линия, мульти-символьная
|
|
95
|
+
// с уплотнителем, и обманка для негативной детекции
|
|
96
|
+
function makeFacade() {
|
|
97
|
+
const [emitPlain, plain] = createListenPair<[number]>()
|
|
98
|
+
const [emitTicker, ticker] = replayListen<[number]>({history: 64, current: 'last'})
|
|
99
|
+
const [emitQuote, quotes] = replayListen<[string, number]>({
|
|
100
|
+
history: 256,
|
|
101
|
+
frame: function lastPerSymbol(tail) {
|
|
102
|
+
const held = new Map<string, ReplayEvent<[string, number]>>()
|
|
103
|
+
for (const ev of tail) { held.delete(ev.event[0]); held.set(ev.event[0], ev) }
|
|
104
|
+
return [...held.values()]
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
const fake = {line: {x: 1}, getSince: 1, keyframe: 'no'}
|
|
108
|
+
return {emitPlain, emitTicker, emitQuote, facade: {plain, ticker, quotes, fake}}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const foldQuotes = (envs: ReplayEvent<[string, number]>[]) => {
|
|
112
|
+
const m: Record<string, number> = {}
|
|
113
|
+
for (const ev of envs) m[ev.event[0]] = ev.event[1]
|
|
114
|
+
return m
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
console.log('\n[rpc-auto] replay-transparent exposure over a real Socket.IO wire')
|
|
119
|
+
|
|
120
|
+
// ============ in-proc: священная линия бросает громко ============
|
|
121
|
+
{
|
|
122
|
+
const [emit, sacred] = replayListen<[number]>({history: 2})
|
|
123
|
+
for (let i = 1; i <= 5; i++) emit(i)
|
|
124
|
+
let threw = false
|
|
125
|
+
try { sacred.frame(0) } catch { threw = true }
|
|
126
|
+
ok(threw, 'sacred line + evicted journal: frame(0) throws (no silent invention)')
|
|
127
|
+
ok(sacred.frame(4).length == 1 && sacred.frame(4)[0].event[0] == 5, 'sacred line + covered gap: exact tail')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============ in-proc: конец стрима (не-конверт) — громко в onError ============
|
|
131
|
+
{
|
|
132
|
+
let err: any = null
|
|
133
|
+
const remote: ReplayRemote<[number]> = {
|
|
134
|
+
line: {on: (cb: any) => { setTimeout(() => cb('___STOP'), 5); return () => {} }},
|
|
135
|
+
since: () => [],
|
|
136
|
+
keyframe: () => null,
|
|
137
|
+
}
|
|
138
|
+
replaySubscribe(remote, () => {}, {since: 0, onError: e => err = e})
|
|
139
|
+
await delay(30)
|
|
140
|
+
ok(err != null, 'non-envelope on line (RPC_STOP) surfaces via onError, not silence')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const world = makeFacade()
|
|
144
|
+
type Facade = typeof world.facade
|
|
145
|
+
const captured: {listenPaths?: string[]} = {}
|
|
146
|
+
const server = await startRealServer(world.facade, undefined, captured)
|
|
147
|
+
const c1 = await startRealClient<Facade>(server.port)
|
|
148
|
+
let gateServer: Awaited<ReturnType<typeof startRealServer>> | null = null
|
|
149
|
+
let c3: Awaited<ReturnType<typeof startRealClient<Facade>>> | null = null
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const deep = c1.client.func
|
|
153
|
+
|
|
154
|
+
// ============ декларация MAP: replay-узлы объявлены, обманка — нет ============
|
|
155
|
+
const lp = captured.listenPaths ?? []
|
|
156
|
+
const partsOf = (key: string) => {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(key)
|
|
159
|
+
if (Array.isArray(parsed)) return parsed.map(String)
|
|
160
|
+
} catch {}
|
|
161
|
+
return key.split('.')
|
|
162
|
+
}
|
|
163
|
+
const samePath = (key: string, path: string[]) => {
|
|
164
|
+
const parts = partsOf(key)
|
|
165
|
+
return parts.length == path.length && parts.every((p, i) => p == path[i])
|
|
166
|
+
}
|
|
167
|
+
const hasPath = (...path: string[]) => lp.includes(rpcPathKey(path)) || lp.some(k => samePath(k, path))
|
|
168
|
+
const startsWithPath = (key: string, path: string[]) => path.every((p, i) => partsOf(key)[i] == p)
|
|
169
|
+
ok(hasPath('ticker') && hasPath('quotes'), `replay keys declared as listen nodes (${json(lp)})`)
|
|
170
|
+
ok(hasPath('ticker', 'line') && hasPath('quotes', 'frameLine'), 'replay wire sub-lines declared additively in Pkt.MAP')
|
|
171
|
+
ok(hasPath('plain'), 'plain Listen declared as before (MAP grows additively)')
|
|
172
|
+
ok(!lp.some(p => startsWithPath(p, ['fake'])), 'object with line/getSince/keyframe props is NOT misdetected (brand)')
|
|
173
|
+
|
|
174
|
+
// ============ легаси-паритет: replay-ключ ведёт себя как plain Listen ============
|
|
175
|
+
const gotPlain: number[] = []
|
|
176
|
+
const gotTicker: number[] = []
|
|
177
|
+
deep.plain.callback((v: number) => gotPlain.push(v))
|
|
178
|
+
deep.ticker.callback((v: number) => gotTicker.push(v))
|
|
179
|
+
await delay(150) // подписки долетели
|
|
180
|
+
for (let i = 1; i <= 5; i++) { world.emitPlain(i); world.emitTicker(i) }
|
|
181
|
+
await waitFor('legacy live events', () => gotPlain.length >= 5 && gotTicker.length >= 5)
|
|
182
|
+
ok(json(gotTicker) == json(gotPlain), `legacy path: replay key delivers exactly what a plain Listen does (${json(gotTicker)})`)
|
|
183
|
+
|
|
184
|
+
// ============ replay-путь на ТОМ ЖЕ соединении (legacy + replay сосуществуют) ============
|
|
185
|
+
// merged-узел структурно совместим с ReplayRemote — deep.ticker уходит как есть
|
|
186
|
+
const seqs: number[] = []
|
|
187
|
+
const vals: number[] = []
|
|
188
|
+
const sub = replaySubscribe(deep.ticker, (v: number) => vals.push(v), {since: 0, onSeq: s => seqs.push(s)})
|
|
189
|
+
await sub.ready
|
|
190
|
+
ok(json(vals) == json([1, 2, 3, 4, 5]), `replaySubscribe since:0 folded journal, no gaps/dups (${json(vals)})`)
|
|
191
|
+
ok(seqs.every((s, i) => i == 0 || s == seqs[i - 1] + 1), 'seq strictly monotonic through catch-up')
|
|
192
|
+
|
|
193
|
+
world.emitTicker(6)
|
|
194
|
+
world.emitPlain(6)
|
|
195
|
+
await waitFor('live after catch-up on both consumers', () => vals.includes(6) && gotTicker.includes(6))
|
|
196
|
+
ok(true, 'legacy and replay subscriptions coexist live on ONE connection')
|
|
197
|
+
|
|
198
|
+
// ============ current:'last' — keyframe без ручного состояния ============
|
|
199
|
+
const kf = await deep.ticker.keyframe()
|
|
200
|
+
ok(kf && kf.event[0] == 6 && kf.seq == 6, `current:'last' keyframe = last envelope (${json(kf)})`)
|
|
201
|
+
|
|
202
|
+
// ============ frame equivalence: мини-кадрик == fold полного хвоста ============
|
|
203
|
+
world.emitQuote('AAPL', 100); world.emitQuote('AAPL', 101); world.emitQuote('MSFT', 300)
|
|
204
|
+
world.emitQuote('AAPL', 102); world.emitQuote('MSFT', 301)
|
|
205
|
+
const fullTail = await deep.quotes.since(0)
|
|
206
|
+
const mini = await deep.quotes.frame(0)
|
|
207
|
+
ok(fullTail.length == 5 && mini.length == 2, `mini-frame condensed 5 events into 2 (${mini.length})`)
|
|
208
|
+
ok(json(foldQuotes(mini)) == json(foldQuotes(fullTail)), 'apply(mini-frame) == apply(full tail) — state equivalent')
|
|
209
|
+
ok(mini.every((ev, i) => i == 0 || ev.seq > mini[i - 1].seq), 'mini-frame envelopes monotonic by seq')
|
|
210
|
+
|
|
211
|
+
// ============ ворота лага (Feature B): policy 'frame' ============
|
|
212
|
+
let fakePending = 0
|
|
213
|
+
gateServer = await startRealServer(world.facade, {pending: () => fakePending, highWater: 5, lowWater: 0, pollMs: 10})
|
|
214
|
+
c3 = await startRealClient<Facade>(gateServer.port)
|
|
215
|
+
const deep3 = c3.client.func
|
|
216
|
+
const gated: Record<string, number> = {}
|
|
217
|
+
let gatedDeliveries = 0
|
|
218
|
+
const gsub = replaySubscribe(deep3.quotes, (sym, px) => { gated[sym] = px; gatedDeliveries++ }, {
|
|
219
|
+
since: (await deep3.quotes.frame(0)).slice(-1)[0]?.seq ?? 0,
|
|
220
|
+
policy: 'frame',
|
|
221
|
+
})
|
|
222
|
+
await gsub.ready
|
|
223
|
+
world.emitQuote('AAPL', 110)
|
|
224
|
+
await waitFor('gated line live while drained', () => gated['AAPL'] == 110)
|
|
225
|
+
ok(true, 'frame policy: live pass-through while socket is drained')
|
|
226
|
+
|
|
227
|
+
fakePending = 100 // клиент «залип»
|
|
228
|
+
await delay(20)
|
|
229
|
+
const before = gatedDeliveries
|
|
230
|
+
for (let i = 0; i < 30; i++) world.emitQuote('AAPL', 200 + i)
|
|
231
|
+
for (let i = 0; i < 20; i++) world.emitQuote('MSFT', 400 + i)
|
|
232
|
+
await delay(120)
|
|
233
|
+
ok(gatedDeliveries == before, `lag: gate dropped everything, no queue (${gatedDeliveries - before} leaked)`)
|
|
234
|
+
|
|
235
|
+
fakePending = 0 // буфер слился
|
|
236
|
+
await waitFor('frame recovery after drain', () => gated['AAPL'] == 229 && gated['MSFT'] == 419)
|
|
237
|
+
ok(gatedDeliveries - before == 2, `recovery = mini-frame: 50 missed events arrived as 2 envelopes (${gatedDeliveries - before})`)
|
|
238
|
+
|
|
239
|
+
world.emitQuote('AAPL', 500)
|
|
240
|
+
await waitFor('live continues after recovery', () => gated['AAPL'] == 500)
|
|
241
|
+
ok(true, 'live stream continues after gate recovery')
|
|
242
|
+
gsub()
|
|
243
|
+
sub()
|
|
244
|
+
|
|
245
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
246
|
+
} finally {
|
|
247
|
+
c1.close()
|
|
248
|
+
c3?.close()
|
|
249
|
+
await delay(20)
|
|
250
|
+
await server.close()
|
|
251
|
+
await gateServer?.close()
|
|
252
|
+
}
|
|
253
|
+
if (fails) process.exit(1)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main().catch(e => { console.error(e); process.exit(1) })
|