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,142 @@
|
|
|
1
|
+
// REAL-SOCKET dedupe: subscription dedupe + refcount + server stats + cap. Port 4106.
|
|
2
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
3
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
4
|
+
|
|
5
|
+
const PORT = 4106
|
|
6
|
+
|
|
7
|
+
// One stream node per facade; emit pushes a value to whoever subscribed.
|
|
8
|
+
// `feed` is a server-side method the client can call to drive an emission.
|
|
9
|
+
function makeObject() {
|
|
10
|
+
const [emitA, listenA] = createListenPair<number>()
|
|
11
|
+
const [emitB, listenB] = createListenPair<number>()
|
|
12
|
+
return {
|
|
13
|
+
// The Listen handle itself IS the node; resolveTransform turns it into
|
|
14
|
+
// {callback, removeCallback}, so the wire path is `streamA.callback`.
|
|
15
|
+
streamA: listenA,
|
|
16
|
+
streamB: listenB,
|
|
17
|
+
// server-side drivers: tell the server to fire an event into a stream.
|
|
18
|
+
feedA: async (v: number) => { emitA(v); return v },
|
|
19
|
+
feedB: async (v: number) => { emitB(v); return v },
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const {check, done} = makeChecker('dedupe')
|
|
25
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
26
|
+
|
|
27
|
+
// capture per-connection server api for subscription stats
|
|
28
|
+
let serverApi: any = null
|
|
29
|
+
const srv = await startRealServer({port: PORT, makeObject, onServer: (api) => { serverApi = api }})
|
|
30
|
+
const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
|
|
31
|
+
const api = cli.api
|
|
32
|
+
|
|
33
|
+
await delay(50) // let handshake (MAP / declared listens) settle
|
|
34
|
+
|
|
35
|
+
// =====================================================================
|
|
36
|
+
// Part 1: two local consumers, SAME address → ONE wire subscription.
|
|
37
|
+
// =====================================================================
|
|
38
|
+
const gotC1: number[] = []
|
|
39
|
+
const gotC2: number[] = []
|
|
40
|
+
const off1 = api.streamA.callback((v: number) => gotC1.push(v))
|
|
41
|
+
const off2 = api.streamA.callback((v: number) => gotC2.push(v))
|
|
42
|
+
await delay(80) // subscription round-trip
|
|
43
|
+
|
|
44
|
+
// Client side: ONE wire sub, with TWO local consumers.
|
|
45
|
+
await check('client wireSubs count == 1 (deduped)',
|
|
46
|
+
() => cli.client.api.subscriptions().length, 1)
|
|
47
|
+
await check('client consumers == 2 (two local on same address)',
|
|
48
|
+
() => cli.client.api.subscriptions()[0].consumers, 2)
|
|
49
|
+
|
|
50
|
+
// Server side: ONE Listen node, ONE wire subscriber (dedupe collapsed the two).
|
|
51
|
+
await check('server subscriptions: 1 node',
|
|
52
|
+
() => serverApi.subscriptions().length, 1)
|
|
53
|
+
await check('server consumers == 1 (single shared wire sub)',
|
|
54
|
+
() => serverApi.subscriptions()[0].consumers, 1)
|
|
55
|
+
|
|
56
|
+
// Fan-out: one server emit reaches BOTH local consumers.
|
|
57
|
+
await api.feedA(7)
|
|
58
|
+
await delay(80)
|
|
59
|
+
await check('fan-out: consumer1 received [7]', () => gotC1, [7])
|
|
60
|
+
await check('fan-out: consumer2 received [7]', () => gotC2, [7])
|
|
61
|
+
|
|
62
|
+
// =====================================================================
|
|
63
|
+
// Part 2: refcount — wire stop only when the LAST consumer leaves.
|
|
64
|
+
// =====================================================================
|
|
65
|
+
off1() // first consumer leaves
|
|
66
|
+
await delay(80)
|
|
67
|
+
await check('after off1: still 1 wire sub (refcount > 0)',
|
|
68
|
+
() => cli.client.api.subscriptions().length, 1)
|
|
69
|
+
await check('after off1: consumers dropped to 1',
|
|
70
|
+
() => cli.client.api.subscriptions()[0]?.consumers, 1)
|
|
71
|
+
await check('after off1: server STILL has the node (wire alive)',
|
|
72
|
+
() => serverApi.subscriptions().length, 1)
|
|
73
|
+
|
|
74
|
+
// Surviving consumer still receives events.
|
|
75
|
+
await api.feedA(11)
|
|
76
|
+
await delay(80)
|
|
77
|
+
await check('after off1: consumer2 still gets events [7,11]', () => gotC2, [7, 11])
|
|
78
|
+
await check('after off1: consumer1 stopped getting events [7]', () => gotC1, [7])
|
|
79
|
+
|
|
80
|
+
off2() // LAST consumer leaves → wire stop fires
|
|
81
|
+
await delay(120) // removeCallback is fire-and-forget; give it round-trip time
|
|
82
|
+
await check('after last off: client wireSubs empty',
|
|
83
|
+
() => cli.client.api.subscriptions().length, 0)
|
|
84
|
+
await check('after last off: server node removed (wire stop fired)',
|
|
85
|
+
() => serverApi.subscriptions().length, 0)
|
|
86
|
+
|
|
87
|
+
// =====================================================================
|
|
88
|
+
// Part 3: maxPerListen cap — over-cap subscriptions silently ignored.
|
|
89
|
+
// Distinct addresses (different non-cb args) on the SAME Listen node each
|
|
90
|
+
// create a wire sub; the cap is per Listen node on the server.
|
|
91
|
+
// =====================================================================
|
|
92
|
+
cli.close()
|
|
93
|
+
await srv.close()
|
|
94
|
+
await delay(50)
|
|
95
|
+
|
|
96
|
+
const CAP = 2
|
|
97
|
+
let serverApi2: any = null
|
|
98
|
+
const srv2 = await startRealServer({
|
|
99
|
+
port: PORT, makeObject,
|
|
100
|
+
serverOpts: {maxPerListen: CAP},
|
|
101
|
+
onServer: (api) => { serverApi2 = api },
|
|
102
|
+
})
|
|
103
|
+
const cli2 = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
|
|
104
|
+
const api2 = cli2.api
|
|
105
|
+
await delay(50)
|
|
106
|
+
|
|
107
|
+
// Three DISTINCT-address subscriptions on streamA: the callback comes FIRST
|
|
108
|
+
// (so server-side listenSocket.callback(z) still gets the fn), a trailing tag
|
|
109
|
+
// arg makes each dedupe address unique → three independent wire subs. Cap is 2.
|
|
110
|
+
const recv: Record<string, number[]> = {s0: [], s1: [], s2: []}
|
|
111
|
+
api2.streamA.callback((v: number) => recv.s0.push(v), 0)
|
|
112
|
+
api2.streamA.callback((v: number) => recv.s1.push(v), 1)
|
|
113
|
+
api2.streamA.callback((v: number) => recv.s2.push(v), 2)
|
|
114
|
+
await delay(120)
|
|
115
|
+
|
|
116
|
+
// The client issues three distinct-address wire subs, but the over-cap one is
|
|
117
|
+
// silently resolved by the server (subscribe returns Promise.resolve()), which
|
|
118
|
+
// fires the client's .then(finish) and tears that wire entry down locally — so
|
|
119
|
+
// the client settles back to CAP live wire subs. (Server-side cap below is the
|
|
120
|
+
// load-bearing assertion; this documents the client-side consequence.)
|
|
121
|
+
await check('client settles to CAP live wire subs (over-cap torn down)',
|
|
122
|
+
() => cli2.client.api.subscriptions().length, CAP)
|
|
123
|
+
|
|
124
|
+
// Server accepted only CAP subscribers on the node; over-cap silently ignored.
|
|
125
|
+
await check('server node consumers capped at maxPerListen',
|
|
126
|
+
() => serverApi2.subscriptions()[0]?.consumers, CAP)
|
|
127
|
+
|
|
128
|
+
// Emit reaches exactly the accepted subscribers (first CAP), not the dropped one.
|
|
129
|
+
await api2.feedA(99)
|
|
130
|
+
await delay(100)
|
|
131
|
+
await check('capped: accepted sub s0 got [99]', () => recv.s0, [99])
|
|
132
|
+
await check('capped: accepted sub s1 got [99]', () => recv.s1, [99])
|
|
133
|
+
await check('capped: over-cap sub s2 got nothing []', () => recv.s2, [])
|
|
134
|
+
|
|
135
|
+
clearTimeout(watchdog)
|
|
136
|
+
cli2.close()
|
|
137
|
+
await srv2.close()
|
|
138
|
+
|
|
139
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/errors.spec.ts — DISPOSABLE real-socket oracle
|
|
3
|
+
// CATEGORY "errors": error propagation across a real WebSocket.
|
|
4
|
+
// - plain Error -> client rejects with the message
|
|
5
|
+
// - MyError with code+data -> code + data preserved over the wire
|
|
6
|
+
// - a rejected CALL does not kill the connection (next call still works)
|
|
7
|
+
// ============================================================
|
|
8
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
9
|
+
import {MyError} from '../../src/toError/myThrow'
|
|
10
|
+
|
|
11
|
+
const PORT = 4104
|
|
12
|
+
|
|
13
|
+
function makeObject() {
|
|
14
|
+
return {
|
|
15
|
+
// happy path — used to prove the socket survives rejections
|
|
16
|
+
add: (a: number, b: number) => a + b,
|
|
17
|
+
ping: () => 'pong',
|
|
18
|
+
|
|
19
|
+
// ===== error-throwing handlers =====
|
|
20
|
+
throwPlain: () => { throw new Error('boom-plain') },
|
|
21
|
+
throwMyErr: () => { throw new MyError('payload-msg', 'E_TEST', {x: 1, tag: 'rich'}) },
|
|
22
|
+
// MyError carrying structured (wire-safe) data — verify nested data survives
|
|
23
|
+
throwNested: () => {
|
|
24
|
+
throw new MyError('nested-data', 'E_NESTED', {list: [1, 2, 3], meta: {ok: true, deep: {y: 9}}})
|
|
25
|
+
},
|
|
26
|
+
// async rejection
|
|
27
|
+
rejectAsync: async () => { await delay(5); throw new MyError('async-fail', 'E_ASYNC', {k: 'v'}) },
|
|
28
|
+
// MyError whose data carries a BigInt — probes errToObj pack behavior.
|
|
29
|
+
// FINDING: errToObj copies e.data RAW (no pack walk), so a BigInt here is
|
|
30
|
+
// handed to socket.io's JSON.stringify and throws server-side, killing
|
|
31
|
+
// the connection. Rich types round-trip in normal RESULTS but NOT in errors.
|
|
32
|
+
throwBigErr: () => { throw new MyError('big-fail', 'E_BIG', {n: 42n}) },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The server runs in THIS same process. The BigInt-in-error-data probe makes the
|
|
37
|
+
// server's socket emit throw synchronously OUTSIDE the rpc try/catch, which would
|
|
38
|
+
// otherwise crash the whole process before ##RESULT## prints. Swallow it so the
|
|
39
|
+
// finding is recorded as a FAIL by the client-side race below, not a hard crash.
|
|
40
|
+
let sawSerializeCrash = false
|
|
41
|
+
process.on('uncaughtException', (e: any) => {
|
|
42
|
+
if (String(e?.message ?? e).includes('BigInt')) { sawSerializeCrash = true; return }
|
|
43
|
+
console.error('UNEXPECTED uncaughtException', e); process.exit(2)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const {check, done} = makeChecker('errors')
|
|
48
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
49
|
+
|
|
50
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
51
|
+
const cli = await startRealClient({port: PORT})
|
|
52
|
+
const api = cli.api
|
|
53
|
+
|
|
54
|
+
await delay(20)
|
|
55
|
+
|
|
56
|
+
// sanity: happy call works before we start throwing
|
|
57
|
+
await check('sanity: add(2,3)=5', () => api.add(2, 3), 5)
|
|
58
|
+
|
|
59
|
+
// plain Error -> client rejects with the same message
|
|
60
|
+
await check('plain Error -> message',
|
|
61
|
+
() => api.throwPlain().then(() => '__no_throw__', (e: any) => e?.message), 'boom-plain')
|
|
62
|
+
|
|
63
|
+
// connection survives a rejected call: a subsequent call still works
|
|
64
|
+
await check('socket alive after plain reject', () => api.ping(), 'pong')
|
|
65
|
+
|
|
66
|
+
// MyError -> revived as a real Error instance on the client
|
|
67
|
+
await check('MyError -> instanceof Error',
|
|
68
|
+
() => api.throwMyErr().then(() => false, (e: any) => e instanceof Error), true)
|
|
69
|
+
|
|
70
|
+
// MyError -> name preserved
|
|
71
|
+
await check('MyError -> name',
|
|
72
|
+
() => api.throwMyErr().then(() => '', (e: any) => e?.name), 'MyError')
|
|
73
|
+
|
|
74
|
+
// MyError -> message preserved
|
|
75
|
+
await check('MyError -> message',
|
|
76
|
+
() => api.throwMyErr().then(() => '', (e: any) => e?.message), 'payload-msg')
|
|
77
|
+
|
|
78
|
+
// MyError -> machine code preserved across the wire
|
|
79
|
+
await check('MyError -> code',
|
|
80
|
+
() => api.throwMyErr().then(() => '', (e: any) => e?.code), 'E_TEST')
|
|
81
|
+
|
|
82
|
+
// MyError -> arbitrary data payload preserved across the wire
|
|
83
|
+
await check('MyError -> data',
|
|
84
|
+
() => api.throwMyErr().then(() => null, (e: any) => e?.data), {x: 1, tag: 'rich'})
|
|
85
|
+
|
|
86
|
+
// socket still alive after the MyError reject
|
|
87
|
+
await check('socket alive after MyError reject', () => api.add(10, 20), 30)
|
|
88
|
+
|
|
89
|
+
// nested/structured (wire-safe) data inside MyError.data survives intact
|
|
90
|
+
await check('MyError -> nested data',
|
|
91
|
+
() => api.throwNested().then(() => null, (e: any) => e?.data),
|
|
92
|
+
{list: [1, 2, 3], meta: {ok: true, deep: {y: 9}}})
|
|
93
|
+
|
|
94
|
+
// async rejection: code + data preserved
|
|
95
|
+
await check('async MyError -> [code, data]',
|
|
96
|
+
() => api.rejectAsync().then(() => null, (e: any) => [e?.code, e?.data]),
|
|
97
|
+
['E_ASYNC', {k: 'v'}])
|
|
98
|
+
|
|
99
|
+
// final liveness BEFORE probing the BigInt-in-error crash
|
|
100
|
+
await check('socket alive before big-err probe', () => api.ping(), 'pong')
|
|
101
|
+
|
|
102
|
+
// FINDING probe: BigInt inside MyError.data. errToObj copies data raw, so the
|
|
103
|
+
// server's socket emit throws "Do not know how to serialize a BigInt" and the
|
|
104
|
+
// connection dies. We RACE the call against a short timeout so a server-side
|
|
105
|
+
// crash surfaces here as a recorded FAIL instead of taking down the process
|
|
106
|
+
// before ##RESULT## prints. Expected (correct) behavior would be a clean
|
|
107
|
+
// rejection with code E_BIG; anything else is the bug.
|
|
108
|
+
await check('MyError BigInt data -> clean reject (FINDING)',
|
|
109
|
+
() => Promise.race([
|
|
110
|
+
api.throwBigErr().then(() => 'resolved', (e: any) => 'rejected:' + e?.code),
|
|
111
|
+
delay(800).then(() => 'no-response(connection-killed)'),
|
|
112
|
+
]),
|
|
113
|
+
'rejected:E_BIG')
|
|
114
|
+
|
|
115
|
+
clearTimeout(watchdog)
|
|
116
|
+
cli.close()
|
|
117
|
+
await srv.close()
|
|
118
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// REAL-SOCKET lifecycle: disconnect teardown drains live subscriptions, fires
|
|
2
|
+
// onDisconnect (client + hub), and hard setToken rotation tears down old subs then
|
|
3
|
+
// reconnects (connectCount++). Default = teardown, NO auto-resubscribe. Port 4108.
|
|
4
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
5
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
6
|
+
|
|
7
|
+
const PORT = 4108
|
|
8
|
+
|
|
9
|
+
// ===== facade: a method + two listen stream nodes =====
|
|
10
|
+
// The server fabricates a fresh facade per connection (see _rs.startRealServer),
|
|
11
|
+
// so each connection gets its own emit handle. We stash the latest emit on a
|
|
12
|
+
// module-level ref so the test body can push ticks into the live stream.
|
|
13
|
+
let emitTicks: ((n: number) => void) | null = null
|
|
14
|
+
let emitClock: ((s: string) => void) | null = null
|
|
15
|
+
|
|
16
|
+
function makeObject() {
|
|
17
|
+
const [tick, tickListen] = createListenPair<number>()
|
|
18
|
+
const [clock, clockListen] = createListenPair<string>()
|
|
19
|
+
emitTicks = tick
|
|
20
|
+
emitClock = clock
|
|
21
|
+
return {
|
|
22
|
+
ping: async () => 'pong',
|
|
23
|
+
// Listen nodes: client subscribes via api.<node>.callback(fn)
|
|
24
|
+
ticks: tickListen,
|
|
25
|
+
clock: clockListen,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const {check, done} = makeChecker('lifecycle')
|
|
31
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
32
|
+
|
|
33
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// PART 1 — client.dispose() drains a live subscription
|
|
37
|
+
// ============================================================
|
|
38
|
+
const cli = await startRealClient({port: PORT})
|
|
39
|
+
const api = cli.api
|
|
40
|
+
|
|
41
|
+
// sanity: a plain call round-trips over the real socket
|
|
42
|
+
await check('CALL ping', () => api.ping(), 'pong')
|
|
43
|
+
|
|
44
|
+
// subscribe to the `ticks` stream; collect ticks; the off-handle is a
|
|
45
|
+
// callable thenable that RESOLVES when the stream ends (teardown/drain).
|
|
46
|
+
const got1: number[] = []
|
|
47
|
+
let drained1 = false
|
|
48
|
+
const off1 = api.ticks.callback((n: number) => got1.push(n))
|
|
49
|
+
off1.then(() => { drained1 = true }) // settle on stream end
|
|
50
|
+
|
|
51
|
+
await delay(60) // let the subscription round-trip to the server
|
|
52
|
+
emitTicks?.(1)
|
|
53
|
+
emitTicks?.(2)
|
|
54
|
+
await delay(60) // ticks travel back over the wire
|
|
55
|
+
|
|
56
|
+
await check('subscription receives live ticks', () => got1.slice(), [1, 2])
|
|
57
|
+
await check('1 live wire subscription before dispose', () => cli.client.api.subscriptions().length, 1)
|
|
58
|
+
|
|
59
|
+
// dispose drains: the consumer promise settles, NO auto-resubscribe
|
|
60
|
+
let cliDisconnectReason: string | null = null
|
|
61
|
+
cli.client.onDisconnect((r: string) => { cliDisconnectReason = r })
|
|
62
|
+
cli.client.dispose('test dispose')
|
|
63
|
+
|
|
64
|
+
await delay(60)
|
|
65
|
+
await check('client.onDisconnect fires with reason', () => cliDisconnectReason, 'test dispose')
|
|
66
|
+
await check('dispose drained the consumer promise (resolved)', () => drained1, true)
|
|
67
|
+
await check('no live subscriptions after dispose', () => cli.client.api.subscriptions().length, 0)
|
|
68
|
+
|
|
69
|
+
// post-dispose ticks must NOT reach the (drained) consumer — honest teardown
|
|
70
|
+
emitTicks?.(99)
|
|
71
|
+
await delay(40)
|
|
72
|
+
await check('no auto-resubscribe: stale ticks ignored after dispose', () => got1.slice(), [1, 2])
|
|
73
|
+
|
|
74
|
+
cli.close()
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// PART 2 — hub.setToken hard rotation: teardown old subs, then reconnect
|
|
78
|
+
// ============================================================
|
|
79
|
+
const cli2 = await startRealClient({port: PORT})
|
|
80
|
+
|
|
81
|
+
let hubDisconnectReason: string | null = null
|
|
82
|
+
let connectFiredCount = 0
|
|
83
|
+
cli2.hub.onDisconnect((r: string) => { hubDisconnectReason = r })
|
|
84
|
+
cli2.hub.onConnect((count: number) => { connectFiredCount = count })
|
|
85
|
+
|
|
86
|
+
await check('connectCount after initial connect', () => cli2.hub.connectCount(), 1)
|
|
87
|
+
|
|
88
|
+
// subscribe on the clock stream of the FIRST connection
|
|
89
|
+
const got2: string[] = []
|
|
90
|
+
let drained2 = false
|
|
91
|
+
const off2 = cli2.api.clock.callback((s: string) => got2.push(s))
|
|
92
|
+
off2.then(() => { drained2 = true })
|
|
93
|
+
|
|
94
|
+
await delay(60)
|
|
95
|
+
emitClock?.('a')
|
|
96
|
+
await delay(60)
|
|
97
|
+
await check('rotation: sub receives tick on old connection', () => got2.slice(), ['a'])
|
|
98
|
+
await check('1 live subscription before rotation', () => cli2.client.api.subscriptions().length, 1)
|
|
99
|
+
|
|
100
|
+
// capture the OLD per-connection client surface (the facade swaps on rotation)
|
|
101
|
+
const oldClient = cli2.client
|
|
102
|
+
|
|
103
|
+
// HARD rotation: setToken tears down old subs (socketAlive:false), fires hub
|
|
104
|
+
// onDisconnect, opens a fresh socket and fires onConnect again (connectCount++).
|
|
105
|
+
await cli2.hub.setToken('rotated-token')
|
|
106
|
+
await delay(120) // let the fresh socket connect + reconnect handshake
|
|
107
|
+
|
|
108
|
+
await check('hub.onDisconnect fired on rotation', () => hubDisconnectReason, 'token rotated')
|
|
109
|
+
await check('old subscription drained on rotation (no auto-resubscribe)', () => drained2, true)
|
|
110
|
+
await check('old client has 0 live subscriptions after rotation', () => oldClient.api.subscriptions().length, 0)
|
|
111
|
+
await check('connectCount incremented to 2 on rotation', () => cli2.hub.connectCount(), 2)
|
|
112
|
+
await check('hub.onConnect fired again with fresh count', () => connectFiredCount, 2)
|
|
113
|
+
|
|
114
|
+
// the NEW connection has NO carried-over subscription (default = teardown)
|
|
115
|
+
await check('fresh connection starts with 0 subscriptions', () => {
|
|
116
|
+
// facade[key] is the new per-connection client after rotation
|
|
117
|
+
const fresh = (cli2.hub.facade as any).api
|
|
118
|
+
return cli2.hub.connectCount() == 2 && fresh.api.subscriptions().length == 0
|
|
119
|
+
}, true)
|
|
120
|
+
|
|
121
|
+
cli2.close()
|
|
122
|
+
|
|
123
|
+
clearTimeout(watchdog)
|
|
124
|
+
await srv.close()
|
|
125
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/limits.spec.ts — DISPOSABLE real-socket oracle
|
|
3
|
+
// CATEGORY: limits — server-side incoming payload caps (serverOpts.limits).
|
|
4
|
+
//
|
|
5
|
+
// The server resolves limits via resolveLimits(serverOpts.limits) and runs
|
|
6
|
+
// every incoming CALL's args through unpack(..., lim). A violation throws a
|
|
7
|
+
// PayloadLimitError (rpc-limits.ts), which the CALL try/catch sends back as a
|
|
8
|
+
// Pkt.RESP error → the client promise rejects with an Error whose name is
|
|
9
|
+
// "PayloadLimitError" and message "Payload limit exceeded: <reason>".
|
|
10
|
+
// Under-limit calls of the same method round-trip normally.
|
|
11
|
+
//
|
|
12
|
+
// Direction note: the configurable cap on a real socket goes through
|
|
13
|
+
// serverOpts.limits → INCOMING (client→server) only. The client-side response
|
|
14
|
+
// cap (unpackResult lim) is opt-in via the client `limits` option, which the
|
|
15
|
+
// shared substrate (startRealClient) does not expose — so we exercise the
|
|
16
|
+
// incoming direction, covering several distinct limit dimensions.
|
|
17
|
+
// ============================================================
|
|
18
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
19
|
+
|
|
20
|
+
const PORT = 4109
|
|
21
|
+
|
|
22
|
+
// echo bounces its single arg straight back; sink takes many args and reports count.
|
|
23
|
+
function makeObject() {
|
|
24
|
+
return {
|
|
25
|
+
echo: (x: any) => x,
|
|
26
|
+
sink: (...xs: any[]) => xs.length,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// classify a call as 'ok'(+value) / 'limit'(PayloadLimitError) / 'other'(unexpected reject)
|
|
31
|
+
async function verdict(p: Promise<any>) {
|
|
32
|
+
try {
|
|
33
|
+
const v = await p
|
|
34
|
+
return {tag: 'ok', value: v}
|
|
35
|
+
} catch (e: any) {
|
|
36
|
+
const name = e?.name
|
|
37
|
+
const msg = String(e?.message ?? e)
|
|
38
|
+
if (name == 'PayloadLimitError' || /Payload limit exceeded/.test(msg)) return {tag: 'limit'}
|
|
39
|
+
return {tag: 'other', error: msg}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const {check, done} = makeChecker('limits')
|
|
45
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
46
|
+
|
|
47
|
+
// Tight incoming caps. Defaults (rpc-limits.ts) are huge; we shrink several
|
|
48
|
+
// dimensions so each is easy to cross with a deliberately oversized payload.
|
|
49
|
+
const limits = {
|
|
50
|
+
maxArgs: 3,
|
|
51
|
+
maxStringLen: 10,
|
|
52
|
+
maxDepth: 4,
|
|
53
|
+
maxKeys: 5,
|
|
54
|
+
maxArrayLen: 4,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const srv = await startRealServer({port: PORT, makeObject, serverOpts: {limits}})
|
|
58
|
+
const cli = await startRealClient({port: PORT})
|
|
59
|
+
const api = cli.api
|
|
60
|
+
await delay(30) // let the strict handshake settle over the real socket
|
|
61
|
+
|
|
62
|
+
// ---- sanity: a plainly under-limit call works end-to-end ----
|
|
63
|
+
await check('under-limit echo round-trips', () => api.echo('short'), 'short')
|
|
64
|
+
|
|
65
|
+
// ===== maxArgs (incoming) =====
|
|
66
|
+
await check('maxArgs: 3 args ok (count)', () => api.sink(1, 2, 3), 3)
|
|
67
|
+
await check('maxArgs: 4 args -> limit', async () => (await verdict(api.sink(1, 2, 3, 4))).tag, 'limit')
|
|
68
|
+
|
|
69
|
+
// ===== maxStringLen (incoming) =====
|
|
70
|
+
await check('maxStringLen: 10-char ok', () => api.echo('0123456789'), '0123456789')
|
|
71
|
+
await check('maxStringLen: 11-char -> limit', async () => (await verdict(api.echo('0123456789X'))).tag, 'limit')
|
|
72
|
+
|
|
73
|
+
// ===== maxDepth (incoming) =====
|
|
74
|
+
// depth counted by walk(): each nested object level is +1. {a:{b:{c:{d:1}}}} = depth 4 → ok; +1 level → limit.
|
|
75
|
+
await check('maxDepth: depth 4 ok', () => api.echo({a: {b: {c: {d: 1}}}}), {a: {b: {c: {d: 1}}}})
|
|
76
|
+
await check('maxDepth: depth 6 -> limit', async () =>
|
|
77
|
+
(await verdict(api.echo({a: {b: {c: {d: {e: {f: 1}}}}}}))).tag, 'limit')
|
|
78
|
+
|
|
79
|
+
// ===== maxKeys (incoming) =====
|
|
80
|
+
await check('maxKeys: 5 keys ok', () => api.echo({a: 1, b: 2, c: 3, d: 4, e: 5}), {a: 1, b: 2, c: 3, d: 4, e: 5})
|
|
81
|
+
await check('maxKeys: 6 keys -> limit', async () =>
|
|
82
|
+
(await verdict(api.echo({a: 1, b: 2, c: 3, d: 4, e: 5, f: 6}))).tag, 'limit')
|
|
83
|
+
|
|
84
|
+
// ===== maxArrayLen (incoming) =====
|
|
85
|
+
await check('maxArrayLen: len 4 ok', () => api.echo([1, 2, 3, 4]), [1, 2, 3, 4])
|
|
86
|
+
await check('maxArrayLen: len 5 -> limit', async () => (await verdict(api.echo([1, 2, 3, 4, 5]))).tag, 'limit')
|
|
87
|
+
|
|
88
|
+
// ===== rejection carries the typed error shape (name + message) =====
|
|
89
|
+
await check('over-limit rejects, under-limit still works after', async () => {
|
|
90
|
+
const over = await verdict(api.echo('this-is-way-too-long'))
|
|
91
|
+
const under = await api.echo('ok')
|
|
92
|
+
return [over.tag, under]
|
|
93
|
+
}, ['limit', 'ok'])
|
|
94
|
+
|
|
95
|
+
clearTimeout(watchdog); cli.close(); await srv.close(); process.exit(done() === 0 ? 0 : 1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/pipe.spec.ts — DISPOSABLE real-socket oracle for PIPE chains
|
|
3
|
+
//
|
|
4
|
+
// Category "pipe": a method returning a transformation chain that the server
|
|
5
|
+
// walks step-by-step (get/call) over a real WebSocket. Covers:
|
|
6
|
+
// • single-stage chain (.value)
|
|
7
|
+
// • multi-stage chain (>1 call stage: .add().mul().value)
|
|
8
|
+
// • rich-type values flowing THROUGH the pipe (Date/Map/BigInt as call args
|
|
9
|
+
// and as final result)
|
|
10
|
+
// • transparent relay (a pipe method returning another pipe handle —
|
|
11
|
+
// server detects IS_RPC_PIPE and __executeRemainingPipe-continues)
|
|
12
|
+
// ============================================================
|
|
13
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
14
|
+
|
|
15
|
+
const PORT = 4103
|
|
16
|
+
|
|
17
|
+
// ===== facade with pipe-able chain methods =====
|
|
18
|
+
function makeObject() {
|
|
19
|
+
// makeBox returns a fresh chainable box; every transform returns a new box,
|
|
20
|
+
// so the server walks `.add(..).mul(..).value` as a real pipe.
|
|
21
|
+
function makeBox(n: number): any {
|
|
22
|
+
return {
|
|
23
|
+
value: n,
|
|
24
|
+
add: (m: number) => makeBox(n + m),
|
|
25
|
+
mul: (m: number) => makeBox(n * m),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
makeBox,
|
|
30
|
+
// rich-type pipe: takes a Date arg, returns a chainable carrying rich data
|
|
31
|
+
makeWhen(d: Date): any {
|
|
32
|
+
return {
|
|
33
|
+
date: d,
|
|
34
|
+
// shift the date forward by ms (rich arg flows through a pipe call stage)
|
|
35
|
+
shift: (ms: number) => makeWhenInner(new Date(d.valueOf() + ms)),
|
|
36
|
+
tags: new Map<string, number>([['a', 1], ['b', 2]]),
|
|
37
|
+
big: 9007199254740993n,
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeWhenInner(d: Date): any {
|
|
43
|
+
return {
|
|
44
|
+
date: d,
|
|
45
|
+
shift: (ms: number) => makeWhenInner(new Date(d.valueOf() + ms)),
|
|
46
|
+
tags: new Map<string, number>([['a', 1], ['b', 2]]),
|
|
47
|
+
big: 9007199254740993n,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
const {check, done} = makeChecker('pipe')
|
|
54
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
55
|
+
|
|
56
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
57
|
+
const cli = await startRealClient({port: PORT})
|
|
58
|
+
const P = cli.client.pipe as any // pipe proxy: supports chaining .a().b().value
|
|
59
|
+
|
|
60
|
+
await delay(20)
|
|
61
|
+
|
|
62
|
+
// --- single-stage chain: method result -> property get ---
|
|
63
|
+
await check('makeBox(10).value', () => P.makeBox(10).value, 10)
|
|
64
|
+
|
|
65
|
+
// --- two-stage chain: one call stage then value ---
|
|
66
|
+
await check('makeBox(10).add(5).value', () => P.makeBox(10).add(5).value, 15)
|
|
67
|
+
|
|
68
|
+
// --- multi-stage chain (>2 call stages): transparent relay across boxes ---
|
|
69
|
+
await check('makeBox(2).add(3).mul(4).value', () => P.makeBox(2).add(3).mul(4).value, 20)
|
|
70
|
+
|
|
71
|
+
// --- order matters: mul before add ---
|
|
72
|
+
await check('makeBox(2).mul(4).add(3).value', () => P.makeBox(2).mul(4).add(3).value, 11)
|
|
73
|
+
|
|
74
|
+
// --- awaiting the chain object itself (no trailing .value): rich object out ---
|
|
75
|
+
await check('makeBox(7).add(1) -> {value:8}', async () => {
|
|
76
|
+
const box = await P.makeBox(7).add(1)
|
|
77
|
+
return box.value
|
|
78
|
+
}, 8)
|
|
79
|
+
|
|
80
|
+
// --- rich type as a Date arg flowing INTO a pipe + Date out ---
|
|
81
|
+
const base = new Date(1700000000000)
|
|
82
|
+
await check('makeWhen(date).date round-trips Date', () => P.makeWhen(base).date, base)
|
|
83
|
+
|
|
84
|
+
// --- rich Date arg + call-stage transform: shift forward by 1000ms ---
|
|
85
|
+
await check('makeWhen(date).shift(1000).date', () => P.makeWhen(base).shift(1000).date, new Date(1700000001000))
|
|
86
|
+
|
|
87
|
+
// --- chained shifts: two call stages over a Date ---
|
|
88
|
+
await check('makeWhen(date).shift(1000).shift(500).date',
|
|
89
|
+
() => P.makeWhen(base).shift(1000).shift(500).date,
|
|
90
|
+
new Date(1700000001500))
|
|
91
|
+
|
|
92
|
+
// --- Map flowing out through a pipe get ---
|
|
93
|
+
await check('makeWhen(date).tags is Map', () => P.makeWhen(base).tags, new Map([['a', 1], ['b', 2]]))
|
|
94
|
+
|
|
95
|
+
// --- BigInt flowing out through a pipe (after a transform stage) ---
|
|
96
|
+
await check('makeWhen(date).shift(1).big is BigInt', () => P.makeWhen(base).shift(1).big, 9007199254740993n)
|
|
97
|
+
|
|
98
|
+
clearTimeout(watchdog); cli.close(); await srv.close(); process.exit(done() === 0 ? 0 : 1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main().catch(e => { console.error(e); process.exit(2) })
|