wenay-common2 1.0.69 → 1.0.70
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 +10 -1
- package/demo/client.ts +100 -0
- package/demo/index.html +28 -0
- package/demo/server.ts +43 -0
- package/doc/changes/1.0.70.md +4 -0
- 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/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// DISPOSABLE ORACLE — verifies the primitive bug-fixes found while reviewing:
|
|
2
|
+
// #1 promiseProgress — .all()/.allSettled() must actually RUN factory tasks (not leave thunks)
|
|
3
|
+
// #2 joinListens.destroy — present + actually unsubscribes (type fix; runtime sanity here)
|
|
4
|
+
// #3 joinListens — no empty-bucket leak in `pending` after a group fires
|
|
5
|
+
// #5 createIterableObject — delete on a read-only proxy must NOT throw (consistent with set())
|
|
6
|
+
// Run: node node_modules/ts-node/dist/bin.js --transpile-only oracle/fixes-primitives.spec.ts
|
|
7
|
+
import {promiseProgress} from '../src/Common/async/promiseProgress'
|
|
8
|
+
import {joinListens} from '../src/Common/events/joinListens'
|
|
9
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
10
|
+
import {createIterableObject} from '../src/Common/async/createIterableObject'
|
|
11
|
+
import {StructMap} from '../src/Common/core/common'
|
|
12
|
+
|
|
13
|
+
let fails = 0
|
|
14
|
+
function assert(cond: any, msg: string) {
|
|
15
|
+
if (cond) console.log(' ok :', msg)
|
|
16
|
+
else { fails++; console.log(' FAIL:', msg) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
// ===== #1 — factories must actually run via .all() =====
|
|
21
|
+
{
|
|
22
|
+
let ran = 0
|
|
23
|
+
const okSeen: number[] = []
|
|
24
|
+
const p = promiseProgress<number>([
|
|
25
|
+
() => { ran++; return 10 }, // sync factory
|
|
26
|
+
async () => { ran++; return 20 }, // async factory
|
|
27
|
+
Promise.resolve(30), // already-a-promise
|
|
28
|
+
])
|
|
29
|
+
p.onOk((data) => okSeen.push(data))
|
|
30
|
+
const res = await p.all()
|
|
31
|
+
assert(ran === 2, '#1 both factories were invoked by .all() (ran=' + ran + ')')
|
|
32
|
+
assert(p.stats().ok === 3, '#1 status.ok counts all 3 tasks (ok=' + p.stats().ok + ')')
|
|
33
|
+
assert(okSeen.length === 3, '#1 listenOk fired for all 3 (got ' + okSeen.length + ')')
|
|
34
|
+
assert(res.every(v => typeof v !== 'function'), '#1 .all() resolves values, not un-run thunks')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ===== #1b — factory runs once across .all()+.allSettled() (memoized, no double-run) =====
|
|
38
|
+
{
|
|
39
|
+
let ran = 0
|
|
40
|
+
const p = promiseProgress<number>([() => { ran++; return 1 }])
|
|
41
|
+
await p.all()
|
|
42
|
+
await p.allSettled()
|
|
43
|
+
assert(ran === 1, '#1b factory runs once across .all()+.allSettled() (ran=' + ran + ')')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ===== #3 — pending must not retain an empty bucket after a group fires =====
|
|
47
|
+
{
|
|
48
|
+
const [emitA, listenA] = createListenPair<[string]>()
|
|
49
|
+
const [emitB, listenB] = createListenPair<[string]>()
|
|
50
|
+
const joined = joinListens([listenA, listenB], (d: any) => d) // key = the value itself
|
|
51
|
+
emitA('x'); emitB('x') // completes group "x" → fires
|
|
52
|
+
assert(!joined.pending.has('x'), '#3 pending has no leftover empty bucket for fired tid')
|
|
53
|
+
assert(joined.pending.size === 0, '#3 pending empty after the only group fired (size=' + joined.pending.size + ')')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ===== #2 — destroy() present + actually unsubscribes (runtime sanity; type checked by tsc) =====
|
|
57
|
+
{
|
|
58
|
+
const [, listenA] = createListenPair<[string]>()
|
|
59
|
+
const joined = joinListens([listenA])
|
|
60
|
+
assert(typeof joined.destroy === 'function', '#2 destroy() is present on the result')
|
|
61
|
+
assert(listenA.count() === 1, '#2 join subscribed to the source')
|
|
62
|
+
joined.destroy()
|
|
63
|
+
assert(listenA.count() === 0, '#2 destroy() unsubscribed from the source')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ===== #5 — delete on a read-only proxy must not throw =====
|
|
67
|
+
{
|
|
68
|
+
const store = new Map<string, number>([['a', 1]])
|
|
69
|
+
const ro = createIterableObject<number>({ resolve: () => store })
|
|
70
|
+
let threw = false
|
|
71
|
+
try { delete (ro as any)['a'] } catch { threw = true }
|
|
72
|
+
assert(!threw, '#5 delete on read-only proxy does not throw')
|
|
73
|
+
assert(store.has('a'), '#5 read-only delete is a no-op (key still present)')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ===== #6 — StructMap.has prefix miss against primitive leaf returns false =====
|
|
77
|
+
{
|
|
78
|
+
const m = new StructMap<readonly number[], number>()
|
|
79
|
+
m.set([1], 42)
|
|
80
|
+
let threw = false
|
|
81
|
+
let has = true
|
|
82
|
+
try { has = m.has([1, 2]) } catch { threw = true }
|
|
83
|
+
assert(!threw, '#6 StructMap.has([1, 2]) does not throw when [1] stores a primitive')
|
|
84
|
+
assert(has === false, '#6 StructMap.has([1, 2]) returns false for a missing composite key')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`\n${fails === 0 ? 'ALL GREEN' : fails + ' FAILURE(S)'}`)
|
|
88
|
+
process.exit(fails === 0 ? 0 : 1)
|
|
89
|
+
}
|
|
90
|
+
main()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/_rs.ts — DISPOSABLE shared harness for REAL-SOCKET mass tests
|
|
3
|
+
//
|
|
4
|
+
// Boots a genuine socket.io server (express + http) on a TCP port and a real
|
|
5
|
+
// socket.io-client hub — the SAME transport pattern as src/Common/rcp/test.ts.
|
|
6
|
+
// Not the in-memory loopback: messages cross a real WebSocket. Each spec uses
|
|
7
|
+
// its OWN port so specs run concurrently without clashing.
|
|
8
|
+
//
|
|
9
|
+
// Disposable oracle (project rule: no standing test suite; real harness is the
|
|
10
|
+
// one in src/Common/rcp/rpc.harness.spec.ts). Delete this dir after the pass.
|
|
11
|
+
// ============================================================
|
|
12
|
+
import express from 'express'
|
|
13
|
+
import {createServer} from 'http'
|
|
14
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
15
|
+
import {io} from 'socket.io-client'
|
|
16
|
+
import {createRpcServerAuto} from '../../src/Common/rcp/rpc-server-auto'
|
|
17
|
+
import {createRpcClientHub} from '../../src/Common/rcp/rpc-clientHub'
|
|
18
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
19
|
+
|
|
20
|
+
export const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
21
|
+
|
|
22
|
+
// ===== real socket.io server =====
|
|
23
|
+
type ServerOpts = {
|
|
24
|
+
port: number
|
|
25
|
+
// фабрика объекта-фасада на КАЖДОЕ подключение (как в реальном сервере)
|
|
26
|
+
makeObject: () => any
|
|
27
|
+
// прокидывается в createRpcServerAuto (auth / limits / maxPerListen / throttle / debug)
|
|
28
|
+
serverOpts?: Record<string, any>
|
|
29
|
+
socketKey?: string
|
|
30
|
+
// доступ к per-connection серверным api (subscriptions stats и т.п.)
|
|
31
|
+
onServer?: (api: any, socket: any) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function startRealServer(opts: ServerOpts) {
|
|
35
|
+
const {port, makeObject, serverOpts = {}, socketKey = 'rpc', onServer} = opts
|
|
36
|
+
const app = express()
|
|
37
|
+
const httpServer = createServer(app)
|
|
38
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
39
|
+
|
|
40
|
+
ioServer.on('connection', socket => {
|
|
41
|
+
const [disconnect, disconnectListen] = createListenPair()
|
|
42
|
+
socket.on('disconnect', () => disconnect())
|
|
43
|
+
const adapter = {
|
|
44
|
+
emit: (key: string, data: any) => socket.emit(key, data),
|
|
45
|
+
on: (key: string, cb: any) => socket.on(key, cb),
|
|
46
|
+
}
|
|
47
|
+
const server = createRpcServerAuto({socket: adapter, socketKey, object: makeObject(), disconnectListen, ...serverOpts})
|
|
48
|
+
onServer?.(server.api, socket)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await new Promise<void>(resolve => httpServer.listen(port, resolve))
|
|
52
|
+
return {
|
|
53
|
+
httpServer, ioServer,
|
|
54
|
+
close: () => new Promise<void>(resolve => { ioServer.close(); httpServer.close(() => resolve()) }),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ===== real socket.io client (via hub) =====
|
|
59
|
+
type ClientOpts = {port: number, token?: string | null, socketKey?: string, opt?: {compact?: boolean}}
|
|
60
|
+
|
|
61
|
+
export async function startRealClient<T extends object = any>(opts: ClientOpts) {
|
|
62
|
+
const {port, token = null, socketKey = 'rpc', opt} = opts
|
|
63
|
+
const hub = createRpcClientHub(
|
|
64
|
+
() => io(`http://localhost:${port}`, {transports: ['websocket'], forceNew: true}),
|
|
65
|
+
(r) => ({api: r<T>(socketKey)}) as const,
|
|
66
|
+
{ opt },
|
|
67
|
+
)
|
|
68
|
+
const clients = await hub.setToken(token)
|
|
69
|
+
await clients.api.readyStrict()
|
|
70
|
+
return {
|
|
71
|
+
hub,
|
|
72
|
+
client: clients.api, // createRpcClient surface (auth/reauth/dispose/onDisconnect/subscriptions/schema)
|
|
73
|
+
api: clients.api.func as any, // transparent proxy — call site
|
|
74
|
+
close: () => hub.socket?.disconnect?.(),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ===== assertions =====
|
|
79
|
+
export type Failure = {name: string, got?: any, expected?: any, error?: string}
|
|
80
|
+
|
|
81
|
+
function eq(a: any, b: any) {
|
|
82
|
+
// нормализуем Date → ISO для сравнения round-trip rich-типов
|
|
83
|
+
const norm = (v: any) => JSON.stringify(v, (_k, val) => val instanceof Date ? ['__date', val.toISOString()]
|
|
84
|
+
: val instanceof Map ? ['__map', [...val]] : val instanceof Set ? ['__set', [...val]]
|
|
85
|
+
: typeof val === 'bigint' ? ['__big', val.toString()] : val)
|
|
86
|
+
return norm(a) === norm(b)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function makeChecker(label: string) {
|
|
90
|
+
let pass = 0, fail = 0
|
|
91
|
+
const failures: Failure[] = []
|
|
92
|
+
async function check(name: string, fn: () => any, expected: any) {
|
|
93
|
+
try {
|
|
94
|
+
const got = await fn()
|
|
95
|
+
if (eq(got, expected)) { pass++; console.log(`PASS [${label}] ${name}`) }
|
|
96
|
+
else { fail++; failures.push({name, got, expected}); console.log(`FAIL [${label}] ${name} got=${JSON.stringify(got)} exp=${JSON.stringify(expected)}`) }
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
fail++; failures.push({name, error: String(e?.stack ?? e)}); console.log(`FAIL [${label}] ${name} ERROR=${String(e?.message ?? e)}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
check,
|
|
103
|
+
summary: () => ({label, total: pass + fail, passed: pass, failed: fail, failures}),
|
|
104
|
+
done: () => { console.log(`\n##RESULT## ${JSON.stringify({label, total: pass + fail, passed: pass, failed: fail, failures})}`); return fail },
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/auth.spec.ts — DISPOSABLE real-socket test
|
|
3
|
+
// Category "auth": in-band auth over a genuine WebSocket.
|
|
4
|
+
// (1) valid token → auth() ok, principal methods callable
|
|
5
|
+
// (2) bad / gated → calls rejected with E_UNAUTHORIZED
|
|
6
|
+
// (3) soft reauth on the LIVE socket → principal swaps (user→admin)
|
|
7
|
+
// WITHOUT dropping a live subscription.
|
|
8
|
+
// ============================================================
|
|
9
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
10
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
11
|
+
|
|
12
|
+
const PORT = 4105
|
|
13
|
+
|
|
14
|
+
// One stream node, shared by BOTH principals → the subscription survives a principal
|
|
15
|
+
// swap (same socket, same Listen handle). Distinct principals: user (read-only) vs
|
|
16
|
+
// admin (read + write) so the swap is observable in method visibility.
|
|
17
|
+
const [emit, listen] = createListenPair<number>()
|
|
18
|
+
const facades: Record<string, any> = {
|
|
19
|
+
'tok-user': {
|
|
20
|
+
stream: listen,
|
|
21
|
+
whoami: () => 'user',
|
|
22
|
+
read: () => 'public-data',
|
|
23
|
+
pulse: (n: number) => { emit(n); return n }, // drive the stream over the wire
|
|
24
|
+
},
|
|
25
|
+
'tok-admin': {
|
|
26
|
+
stream: listen,
|
|
27
|
+
whoami: () => 'admin',
|
|
28
|
+
read: () => 'public-data',
|
|
29
|
+
write: (x: number) => x * 10, // admin-only method
|
|
30
|
+
pulse: (n: number) => { emit(n); return n },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
function resolveAuth(token: any) {
|
|
34
|
+
const object = facades[token]
|
|
35
|
+
if (!object) throw new Error('bad token')
|
|
36
|
+
return {object, ack: {ok: true, who: object.whoami()}}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
const {check, done} = makeChecker('auth')
|
|
41
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
42
|
+
|
|
43
|
+
// gate=true: the EMPTY initial object is protected; the real principal surface only
|
|
44
|
+
// appears after a successful HELLO (resolveAuth → principal facade).
|
|
45
|
+
const srv = await startRealServer({
|
|
46
|
+
port: PORT,
|
|
47
|
+
makeObject: () => ({}), // empty until HELLO
|
|
48
|
+
serverOpts: {auth: {resolveAuth, gate: true}},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ---- (1) valid token: auth resolves ok, principal methods callable ----
|
|
52
|
+
const cliUser = await startRealClient({port: PORT, token: 'tok-user'})
|
|
53
|
+
const userApi = cliUser.api
|
|
54
|
+
|
|
55
|
+
await check('valid token: authAck ok', async () => (await cliUser.client.auth())?.ok, true)
|
|
56
|
+
await check('valid token: authAck who=user', async () => (await cliUser.client.auth())?.who, 'user')
|
|
57
|
+
await check('user principal: read() callable', () => userApi.read(), 'public-data')
|
|
58
|
+
await check('user principal: whoami=user', () => userApi.whoami(), 'user')
|
|
59
|
+
// user facade has NO write → must reject (method absent in principal routeMap)
|
|
60
|
+
await check('user principal: write() rejected (not in facade)',
|
|
61
|
+
() => userApi.write(5).then(() => 'ok', () => 'rejected'), 'rejected')
|
|
62
|
+
|
|
63
|
+
// ---- (2) bad token / gated: calls rejected with E_UNAUTHORIZED ----
|
|
64
|
+
const cliBad = await startRealClient({port: PORT, token: 'tok-nope'})
|
|
65
|
+
await check('bad token: authAck ok=false', async () => (await cliBad.client.auth())?.ok, false)
|
|
66
|
+
await check('gated: call rejected code E_UNAUTHORIZED',
|
|
67
|
+
() => (cliBad.api.read() as Promise<any>).catch((e: any) => e?.code), 'E_UNAUTHORIZED')
|
|
68
|
+
|
|
69
|
+
// ---- (3) soft reauth on the LIVE socket: user → admin, subscription survives ----
|
|
70
|
+
const got: number[] = []
|
|
71
|
+
const off = cliUser.api.stream.callback((v: number) => got.push(v))
|
|
72
|
+
await delay(50) // let the subscription round-trip
|
|
73
|
+
await userApi.pulse(1) // emit BEFORE reauth (as user)
|
|
74
|
+
await delay(50)
|
|
75
|
+
await check('pre-reauth: stream tick received', async () => got.slice(), [1])
|
|
76
|
+
|
|
77
|
+
// swap principal on the SAME live socket (no disconnect) via hub.reauth
|
|
78
|
+
const acks = await cliUser.hub.reauth('tok-admin')
|
|
79
|
+
await check('reauth: hub.reauth resolved ok', async () => acks?.[0]?.ok, true)
|
|
80
|
+
await check('reauth: principal swapped who=admin', async () => (await cliUser.client.auth())?.who, 'admin')
|
|
81
|
+
await check('reauth: admin sees write() now', () => cliUser.api.write(4), 40)
|
|
82
|
+
await check('reauth: whoami flipped to admin', () => cliUser.api.whoami(), 'admin')
|
|
83
|
+
|
|
84
|
+
await userApi.pulse(2) // emit AFTER reauth — same subscription
|
|
85
|
+
await delay(50)
|
|
86
|
+
await check('reauth: subscription survived (live tick after swap)', async () => got.slice(), [1, 2])
|
|
87
|
+
|
|
88
|
+
off()
|
|
89
|
+
clearTimeout(watchdog); cliUser.close(); cliBad.close(); await srv.close(); process.exit(done() === 0 ? 0 : 1)
|
|
90
|
+
}
|
|
91
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/callbacks.spec.ts — DISPOSABLE real-socket oracle
|
|
3
|
+
// CATEGORY "callbacks": callbacks-as-args (Pkt.CB) + listen streaming
|
|
4
|
+
// subscriptions over a genuine WebSocket. Port 4102 (own port, no clash).
|
|
5
|
+
// ============================================================
|
|
6
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
7
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
8
|
+
|
|
9
|
+
const PORT = 4102
|
|
10
|
+
|
|
11
|
+
// Stream nodes live in module scope so the facade factory closes over the SAME
|
|
12
|
+
// listen handles every connection — we drive them from the test below.
|
|
13
|
+
const [tick, tickListen] = createListenPair<[{n: number; at: Date; tags: Set<string>}]>()
|
|
14
|
+
const [multiTick, multiListen] = createListenPair<[number, string]>()
|
|
15
|
+
|
|
16
|
+
function makeObject() {
|
|
17
|
+
return {
|
|
18
|
+
// ── callback-as-arg (Pkt.CB): server invokes the passed callback N times ──
|
|
19
|
+
counter: {
|
|
20
|
+
run: async (a: {n: number; callback: (i: number, when: Date) => void}) => {
|
|
21
|
+
// emit `n` ticks in order, each with a rich Date arg; resolve when done
|
|
22
|
+
for (let i = 0; i < a.n; i++) {
|
|
23
|
+
a.callback(i, new Date(Date.UTC(2026, 0, 1) + i * 1000))
|
|
24
|
+
await delay(5)
|
|
25
|
+
}
|
|
26
|
+
return a.n
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
// ── listen stream node: client subscribes via api.stream.callback(fn) ──
|
|
30
|
+
stream: tickListen, // single rich object arg
|
|
31
|
+
multi: multiListen, // two args (number, string)
|
|
32
|
+
// ── control surface to end the single-arg stream (→ CB_END / await off) ──
|
|
33
|
+
endStream: async () => { tickListen.close(); return true },
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const {check, done} = makeChecker('callbacks')
|
|
39
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 40000)
|
|
40
|
+
|
|
41
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
42
|
+
const cli = await startRealClient({port: PORT})
|
|
43
|
+
const api = cli.api
|
|
44
|
+
|
|
45
|
+
// ===== 1. Callback-as-arg (Pkt.CB): all ticks arrive in order, rich Date =====
|
|
46
|
+
{
|
|
47
|
+
const got: Array<{i: number; when: Date}> = []
|
|
48
|
+
const ret = await api.counter.run({n: 4, callback: (i: number, when: Date) => { got.push({i, when}) }})
|
|
49
|
+
await delay(30) // let any trailing CB packets land after RESP
|
|
50
|
+
await check('cb-arg: return value = n', () => ret, 4)
|
|
51
|
+
await check('cb-arg: tick count', () => got.length, 4)
|
|
52
|
+
await check('cb-arg: indices in order', () => got.map(g => g.i), [0, 1, 2, 3])
|
|
53
|
+
await check('cb-arg: rich Date round-trips',
|
|
54
|
+
() => got.map(g => g.when),
|
|
55
|
+
[0, 1, 2, 3].map(i => new Date(Date.UTC(2026, 0, 1) + i * 1000)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ===== 2. listen stream: ticks arrive with rich values =====
|
|
59
|
+
{
|
|
60
|
+
const a: Array<{n: number; at: Date; tags: Set<string>}> = []
|
|
61
|
+
const off = (api as any).stream.callback((v: any) => a.push(v))
|
|
62
|
+
await delay(40) // subscription round-trips over the real socket
|
|
63
|
+
tick({n: 1, at: new Date('2026-03-03T00:00:00.000Z'), tags: new Set(['x', 'y'])})
|
|
64
|
+
tick({n: 2, at: new Date('2026-03-04T00:00:00.000Z'), tags: new Set(['z'])})
|
|
65
|
+
await delay(40)
|
|
66
|
+
await check('stream: tick count', () => a.length, 2)
|
|
67
|
+
await check('stream: rich values (Date+Set) round-trip',
|
|
68
|
+
() => a,
|
|
69
|
+
[
|
|
70
|
+
{n: 1, at: new Date('2026-03-03T00:00:00.000Z'), tags: new Set(['x', 'y'])},
|
|
71
|
+
{n: 2, at: new Date('2026-03-04T00:00:00.000Z'), tags: new Set(['z'])},
|
|
72
|
+
])
|
|
73
|
+
// off() stops further ticks
|
|
74
|
+
off()
|
|
75
|
+
await delay(20)
|
|
76
|
+
tick({n: 3, at: new Date('2026-03-05T00:00:00.000Z'), tags: new Set()})
|
|
77
|
+
await delay(40)
|
|
78
|
+
await check('stream: off() stops further ticks', () => a.length, 2)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ===== 3. MULTIPLE independent subscribers each get every tick =====
|
|
82
|
+
{
|
|
83
|
+
const b: number[] = [], c: number[] = []
|
|
84
|
+
const offB = (api as any).multi.callback((n: number, _s: string) => b.push(n))
|
|
85
|
+
await delay(20)
|
|
86
|
+
const offC = (api as any).multi.callback((n: number, _s: string) => c.push(n))
|
|
87
|
+
await delay(40)
|
|
88
|
+
multiTick(10, 'a')
|
|
89
|
+
multiTick(20, 'b')
|
|
90
|
+
await delay(40)
|
|
91
|
+
await check('multi-sub: first subscriber got every tick', () => b, [10, 20])
|
|
92
|
+
await check('multi-sub: second subscriber got every tick', () => c, [10, 20])
|
|
93
|
+
// drop one; the other keeps receiving
|
|
94
|
+
offB()
|
|
95
|
+
await delay(20)
|
|
96
|
+
multiTick(30, 'c')
|
|
97
|
+
await delay(40)
|
|
98
|
+
await check('multi-sub: dropped subscriber stops', () => b, [10, 20])
|
|
99
|
+
await check('multi-sub: surviving subscriber continues', () => c, [10, 20, 30])
|
|
100
|
+
offC()
|
|
101
|
+
await delay(20)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ===== 4. CB_END / stream end resolves the off-handle (await off) =====
|
|
105
|
+
{
|
|
106
|
+
let resolved = false
|
|
107
|
+
const off = (api as any).stream.callback(() => {})
|
|
108
|
+
await delay(40)
|
|
109
|
+
const awaited = (async () => { await off; resolved = true })()
|
|
110
|
+
// close the server stream → CB_END → off-handle promise resolves
|
|
111
|
+
await api.endStream()
|
|
112
|
+
await Promise.race([awaited, delay(500)])
|
|
113
|
+
await check('stream-end: await off resolved on CB_END', () => resolved, true)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clearTimeout(watchdog)
|
|
117
|
+
cli.close()
|
|
118
|
+
await srv.close()
|
|
119
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// oracle/realsocket/caps.spec.ts — DISPOSABLE real-socket oracle
|
|
3
|
+
// CATEGORY "caps": capability NEGOTIATION over a REAL WebSocket.
|
|
4
|
+
//
|
|
5
|
+
// Verifies the handshake-negotiated optimization switch (opt.compact):
|
|
6
|
+
// 1) default (both new) → COMPACT negotiated → server uses Pkt.CBV (CBV>0).
|
|
7
|
+
// 2) client opt:{compact:false}→ client advertises NO COMPACT (silent) → server stays
|
|
8
|
+
// on plain Pkt.CB (CBV==0, CB>0). Ticks still intact.
|
|
9
|
+
// 3) server opt:{compact:false}→ server advertises caps without COMPACT → no compaction
|
|
10
|
+
// even though the client supports it (CBV==0, CB>0).
|
|
11
|
+
// In all cases data round-trips byte-correct (incl. Date), proving the optimization is
|
|
12
|
+
// purely a wire choice negotiated at handshake — outcome-equivalent either way.
|
|
13
|
+
// ============================================================
|
|
14
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
15
|
+
import {listen as createListenPair} from '../../src/Common/events/Listen'
|
|
16
|
+
|
|
17
|
+
const PORT = 4120
|
|
18
|
+
|
|
19
|
+
// one scenario: boot a server (opt = serverOpt) + connect a client (opt = clientOpt),
|
|
20
|
+
// fire N monomorphic ticks, count CBV(9)/CB(2) packets on the real server socket.
|
|
21
|
+
async function scenario(port: number, serverOpt?: {compact?: boolean}, clientOpt?: {compact?: boolean}) {
|
|
22
|
+
let emit: ((v: any) => void) | null = null
|
|
23
|
+
function makeObject() {
|
|
24
|
+
const [e, listen] = createListenPair<any>()
|
|
25
|
+
emit = e
|
|
26
|
+
return {stream: listen}
|
|
27
|
+
}
|
|
28
|
+
let cbv = 0, cb = 0
|
|
29
|
+
const srv = await startRealServer({
|
|
30
|
+
port, makeObject,
|
|
31
|
+
serverOpts: serverOpt ? {opt: serverOpt} : {},
|
|
32
|
+
onServer: (_api, socket) => {
|
|
33
|
+
const orig = socket.emit.bind(socket)
|
|
34
|
+
socket.emit = (key: string, d: any) => {
|
|
35
|
+
if (Array.isArray(d)) { if (d[0] === 9) cbv++; else if (d[0] === 2) cb++ }
|
|
36
|
+
return orig(key, d)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const cli = await startRealClient({port, opt: clientOpt})
|
|
41
|
+
const got: any[] = []
|
|
42
|
+
const off = cli.api.stream.callback((v: any) => got.push(v))
|
|
43
|
+
await delay(60) // subscription round-trip + CAPS handshake over the real socket
|
|
44
|
+
const N = 10
|
|
45
|
+
for (let i = 0; i < N; i++) { emit!({a: i, when: new Date(i * 1000), tag: 'x'}); await delay(8) }
|
|
46
|
+
await delay(120)
|
|
47
|
+
off()
|
|
48
|
+
await delay(20)
|
|
49
|
+
cli.close()
|
|
50
|
+
await srv.close()
|
|
51
|
+
return {got, cbv, cb, N}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
const {check, done} = makeChecker('caps')
|
|
56
|
+
const watchdog = setTimeout(() => { console.error('WATCHDOG timeout'); process.exit(3) }, 60000)
|
|
57
|
+
|
|
58
|
+
// ---- 1) default: COMPACT negotiated (both peers new) ----
|
|
59
|
+
{
|
|
60
|
+
const r = await scenario(PORT)
|
|
61
|
+
await check('default: all ticks intact', () => r.got.length, r.N)
|
|
62
|
+
await check('default: last tick exact (Date)', () => [r.got[r.N - 1].a, r.got[r.N - 1].tag, r.got[r.N - 1].when], [r.N - 1, 'x', new Date((r.N - 1) * 1000)])
|
|
63
|
+
await check('default: compaction engaged (CBV>0)', () => r.cbv > 0, true)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- 2) client opt:{compact:false}: client does NOT advertise → server plain CB ----
|
|
67
|
+
{
|
|
68
|
+
const r = await scenario(PORT + 1, undefined, {compact: false})
|
|
69
|
+
await check('client-off: all ticks intact', () => r.got.length, r.N)
|
|
70
|
+
await check('client-off: last tick exact (Date)', () => [r.got[r.N - 1].a, r.got[r.N - 1].when], [r.N - 1, new Date((r.N - 1) * 1000)])
|
|
71
|
+
await check('client-off: NO compaction (CBV==0)', () => r.cbv, 0)
|
|
72
|
+
await check('client-off: server sent plain CB (CB>0)', () => r.cb > 0, true)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 3) server opt:{compact:false}: server advertises no COMPACT → no compaction ----
|
|
76
|
+
{
|
|
77
|
+
const r = await scenario(PORT + 2, {compact: false})
|
|
78
|
+
await check('server-off: all ticks intact', () => r.got.length, r.N)
|
|
79
|
+
await check('server-off: last tick exact (Date)', () => [r.got[r.N - 1].a, r.got[r.N - 1].when], [r.N - 1, new Date((r.N - 1) * 1000)])
|
|
80
|
+
await check('server-off: NO compaction (CBV==0)', () => r.cbv, 0)
|
|
81
|
+
await check('server-off: server sent plain CB (CB>0)', () => r.cb > 0, true)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- 4) subscribe via .on(cb) over the REAL web (idiomatic alias of .callback) ----
|
|
85
|
+
{
|
|
86
|
+
let emit: ((v: any) => void) | null = null
|
|
87
|
+
const srv = await startRealServer({port: PORT + 3, makeObject: () => { const [e, l] = createListenPair<any>(); emit = e; return {stream: l} }})
|
|
88
|
+
const cli = await startRealClient({port: PORT + 3})
|
|
89
|
+
const got: any[] = []
|
|
90
|
+
const off = (cli.api.stream as any).on((v: any) => got.push(v)) // .on вместо .callback — «факт установки колбэка»
|
|
91
|
+
await delay(60)
|
|
92
|
+
for (let i = 0; i < 5; i++) { emit!({n: i}); await delay(8) }
|
|
93
|
+
await delay(100)
|
|
94
|
+
await check('on-web: .on(cb) subscribed + streamed', () => got.map((v: any) => v.n), [0, 1, 2, 3, 4])
|
|
95
|
+
if (typeof off === 'function') off()
|
|
96
|
+
await delay(20)
|
|
97
|
+
cli.close()
|
|
98
|
+
await srv.close()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- 5) bare-on exposure ({ stream: listen.on }) + once over the REAL web ----
|
|
102
|
+
{
|
|
103
|
+
let emit: ((v: any) => void) | null = null
|
|
104
|
+
const srv = await startRealServer({port: PORT + 4, makeObject: () => { const [e, l] = createListenPair<any>(); emit = e; return {stream: (l as any).on} }})
|
|
105
|
+
const cli = await startRealClient({port: PORT + 4})
|
|
106
|
+
const got: any[] = []
|
|
107
|
+
;(cli.api.stream as any).on((v: any) => got.push(v)) // exposed ТОЛЬКО listen.on → клиент получил подписку
|
|
108
|
+
await delay(60)
|
|
109
|
+
for (let i = 0; i < 4; i++) { emit!({n: i}); await delay(8) }
|
|
110
|
+
await delay(100)
|
|
111
|
+
await check('bare-on: { stream: listen.on } streams over real web', () => got.map((v: any) => v.n), [0, 1, 2, 3])
|
|
112
|
+
const one: any[] = []
|
|
113
|
+
;(cli.api.stream as any).once((v: any) => one.push(v))
|
|
114
|
+
await delay(40); emit!({n: 99}); await delay(8); emit!({n: 100}); await delay(50)
|
|
115
|
+
await check('once: exactly one event over real web', () => one.map((v: any) => v.n), [99])
|
|
116
|
+
cli.close()
|
|
117
|
+
await srv.close()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clearTimeout(watchdog)
|
|
121
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch(e => { console.error(e); process.exit(2) })
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// REAL-SOCKET core: CALL + rich-type round-trip both ways. Port 4101.
|
|
2
|
+
import {startRealServer, startRealClient, makeChecker, delay} from './_rs'
|
|
3
|
+
|
|
4
|
+
const PORT = 4101
|
|
5
|
+
|
|
6
|
+
function makeObject() {
|
|
7
|
+
return {
|
|
8
|
+
math: {
|
|
9
|
+
add: async (a: number, b: number) => a + b,
|
|
10
|
+
mul: async (a: number, b: number) => a * b,
|
|
11
|
+
},
|
|
12
|
+
echo: async (v: any) => v,
|
|
13
|
+
rich: async () => ({
|
|
14
|
+
when: new Date(123456),
|
|
15
|
+
map: new Map<string, number>([['a', 1], ['b', 2]]),
|
|
16
|
+
set: new Set([1, 2, 3]),
|
|
17
|
+
big: 9007199254740993n,
|
|
18
|
+
}),
|
|
19
|
+
roundtrip: async (d: Date, m: Map<string, number>) => ({d, m, n: (m.get('x') ?? 0) + 1}),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const {check, done} = makeChecker('core')
|
|
25
|
+
const srv = await startRealServer({port: PORT, makeObject})
|
|
26
|
+
const cli = await startRealClient<ReturnType<typeof makeObject>>({port: PORT})
|
|
27
|
+
const api = cli.api
|
|
28
|
+
|
|
29
|
+
await check('CALL math.add', () => api.math.add(5, 7), 12)
|
|
30
|
+
await check('CALL math.mul', () => api.math.mul(3, 4), 12)
|
|
31
|
+
await check('CALL echo primitive', () => api.echo('hello'), 'hello')
|
|
32
|
+
await check('CALL echo nested obj', () => api.echo({a: [1, 2], b: {c: true}}), {a: [1, 2], b: {c: true}})
|
|
33
|
+
|
|
34
|
+
await check('rich: Date round-trips', async () => (await api.rich()).when, new Date(123456))
|
|
35
|
+
await check('rich: Map round-trips', async () => [...(await api.rich()).map], [['a', 1], ['b', 2]])
|
|
36
|
+
await check('rich: Set round-trips', async () => [...(await api.rich()).set], [1, 2, 3])
|
|
37
|
+
await check('rich: BigInt round-trips', async () => (await api.rich()).big === 9007199254740993n, true)
|
|
38
|
+
|
|
39
|
+
await check('arg→server: Date+Map sent up', async () => {
|
|
40
|
+
const r = await api.roundtrip(new Date(999), new Map([['x', 41]]))
|
|
41
|
+
return [r.d, [...r.m], r.n]
|
|
42
|
+
}, [new Date(999), [['x', 41]], 42])
|
|
43
|
+
|
|
44
|
+
cli.close()
|
|
45
|
+
await srv.close()
|
|
46
|
+
process.exit(done() === 0 ? 0 : 1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch(e => { console.error(e); process.exit(2) })
|