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.
Files changed (89) hide show
  1. package/README.md +11 -1
  2. package/demo/client.ts +222 -0
  3. package/demo/index.html +58 -0
  4. package/demo/server.ts +93 -0
  5. package/doc/INTENT.md +31 -0
  6. package/doc/ROADMAP.md +19 -1
  7. package/doc/changes/1.0.70.md +4 -0
  8. package/doc/changes/1.0.71.md +9 -0
  9. package/doc/changes/1.0.72.md +6 -0
  10. package/doc/changes/1.0.73.md +6 -0
  11. package/doc/wenay-common2-rare.md +29 -8
  12. package/doc/wenay-common2.md +13 -2
  13. package/lib/Common/media/media-index.d.ts +1 -0
  14. package/lib/Common/media/media-index.js +1 -0
  15. package/lib/Common/media/media-source.d.ts +3 -0
  16. package/lib/Common/media/media-source.js +136 -27
  17. package/lib/Common/media/media-view.d.ts +42 -0
  18. package/lib/Common/media/media-view.js +191 -0
  19. package/lib/Common/peer/peer-client.d.ts +12 -5
  20. package/lib/Common/peer/peer-client.js +53 -3
  21. package/lib/Common/peer/peer-host.d.ts +8 -4
  22. package/lib/Common/peer/peer-host.js +2 -2
  23. package/lib/Common/peer/peer-relay.d.ts +10 -2
  24. package/lib/Common/peer/peer-relay.js +40 -18
  25. package/observe/PLAN.md +131 -0
  26. package/observe/README.md +226 -0
  27. package/observe/hot-write.test.ts +95 -0
  28. package/observe/listen-core.test.ts +66 -0
  29. package/observe/listen-store.test.ts +56 -0
  30. package/observe/listen.test.ts +92 -0
  31. package/observe/reactive.test.ts +270 -0
  32. package/observe/reactive.ts +1 -0
  33. package/observe/store-manager.test.ts +118 -0
  34. package/observe/store-mirror.example.ts +74 -0
  35. package/observe/store.test.ts +235 -0
  36. package/observe/store.ts +1 -0
  37. package/observe/usage-real-socket.ts +174 -0
  38. package/observe/usage.ts +200 -0
  39. package/oracle/PASSED.md +27 -0
  40. package/oracle/README.md +12 -0
  41. package/oracle/fixes-primitives.spec.ts +90 -0
  42. package/oracle/realsocket/_rs.ts +106 -0
  43. package/oracle/realsocket/auth.spec.ts +91 -0
  44. package/oracle/realsocket/callbacks.spec.ts +122 -0
  45. package/oracle/realsocket/caps.spec.ts +124 -0
  46. package/oracle/realsocket/core.spec.ts +49 -0
  47. package/oracle/realsocket/dedupe.spec.ts +142 -0
  48. package/oracle/realsocket/errors.spec.ts +121 -0
  49. package/oracle/realsocket/lifecycle.spec.ts +128 -0
  50. package/oracle/realsocket/limits.spec.ts +98 -0
  51. package/oracle/realsocket/pipe.spec.ts +101 -0
  52. package/oracle/realsocket/shape.spec.ts +124 -0
  53. package/oracle/realsocket/slimv2.spec.ts +116 -0
  54. package/oracle/realsocket/stress.spec.ts +132 -0
  55. package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
  56. package/oracle/regression/async-queues.spec.ts +254 -0
  57. package/oracle/regression/bytestream.spec.ts +152 -0
  58. package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
  59. package/oracle/regression/core-clone-equal.spec.ts +124 -0
  60. package/oracle/regression/data-structures.spec.ts +135 -0
  61. package/oracle/regression/listen-events.spec.ts +206 -0
  62. package/oracle/regression/observe-core.spec.ts +278 -0
  63. package/oracle/regression/package-export.spec.ts +120 -0
  64. package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
  65. package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
  66. package/oracle/regression/store-each.spec.ts +209 -0
  67. package/package.json +6 -1
  68. package/replay/PLAN.md +171 -0
  69. package/replay/canvas-socket.test.ts +187 -0
  70. package/replay/coalesce.test.ts +260 -0
  71. package/replay/conflate-socket.test.ts +288 -0
  72. package/replay/conflate.test.ts +225 -0
  73. package/replay/history.test.ts +222 -0
  74. package/replay/media-socket.test.ts +157 -0
  75. package/replay/media-view.test.ts +164 -0
  76. package/replay/offline-store-socket.test.ts +290 -0
  77. package/replay/offline-store.test.ts +156 -0
  78. package/replay/peer-repair.test.ts +184 -0
  79. package/replay/peer-sdk.test.ts +228 -0
  80. package/replay/replay-listen.test.ts +156 -0
  81. package/replay/route-coordinator.test.ts +314 -0
  82. package/replay/route-handoff.test.ts +150 -0
  83. package/replay/route-webrtc.test.ts +321 -0
  84. package/replay/rpc-auto.test.ts +256 -0
  85. package/replay/socket-replay.test.ts +199 -0
  86. package/replay/staleness.test.ts +176 -0
  87. package/replay/store-replay.test.ts +151 -0
  88. package/replay/video-socket.demo.ts +200 -0
  89. 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) })