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,278 @@
|
|
|
1
|
+
import * as srcApi from '../../src/Common/Observe/reactive'
|
|
2
|
+
import * as observeApi from '../../observe/reactive'
|
|
3
|
+
|
|
4
|
+
type Fn = () => void
|
|
5
|
+
type Api = typeof srcApi
|
|
6
|
+
|
|
7
|
+
let failures = 0
|
|
8
|
+
const tests: Array<{name: string; run: () => void | Promise<void>}> = []
|
|
9
|
+
|
|
10
|
+
function test(name: string, run: () => void | Promise<void>) {
|
|
11
|
+
tests.push({name, run})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assert(cond: unknown, message: string) {
|
|
15
|
+
if (!cond) throw new Error(message)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertEq<T>(actual: T, expected: T, message: string) {
|
|
19
|
+
if (!Object.is(actual, expected)) {
|
|
20
|
+
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertDeepEq(actual: unknown, expected: unknown, message: string) {
|
|
25
|
+
const a = JSON.stringify(actual)
|
|
26
|
+
const e = JSON.stringify(expected)
|
|
27
|
+
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertThrows(run: () => void, match: RegExp, message: string) {
|
|
31
|
+
try {
|
|
32
|
+
run()
|
|
33
|
+
} catch (e: any) {
|
|
34
|
+
assert(match.test(String(e?.message ?? e)), `${message}: wrong error ${String(e?.message ?? e)}`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`${message}: did not throw`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createManualDrain() {
|
|
41
|
+
const queue: Fn[] = []
|
|
42
|
+
return {
|
|
43
|
+
opts: {drain: (f: Fn) => { queue.push(f) }},
|
|
44
|
+
get pending() { return queue.length },
|
|
45
|
+
flushOne() {
|
|
46
|
+
const f = queue.shift()
|
|
47
|
+
if (f) f()
|
|
48
|
+
},
|
|
49
|
+
flushAll(limit = 20) {
|
|
50
|
+
let count = 0
|
|
51
|
+
while (queue.length) {
|
|
52
|
+
if (++count > limit) throw new Error('manual drain did not settle')
|
|
53
|
+
queue.shift()!()
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
clear() { queue.length = 0 },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tick() {
|
|
61
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addCoreSuite(label: string, api: Api) {
|
|
65
|
+
const {reactive, onUpdate, flushReactive, listenUpdate} = api
|
|
66
|
+
|
|
67
|
+
test(`${label}: nested subscriptions fire only for affected branches`, () => {
|
|
68
|
+
const drain = createManualDrain()
|
|
69
|
+
const s = reactive<any>({a: {b: {c: 1}, d: 1}, z: 1}, drain.opts)
|
|
70
|
+
let root = 0, a = 0, b = 0
|
|
71
|
+
onUpdate(s, () => root++)
|
|
72
|
+
onUpdate(s.a, () => a++)
|
|
73
|
+
onUpdate(s.a.b, () => b++)
|
|
74
|
+
|
|
75
|
+
s.a.b.c = 2
|
|
76
|
+
drain.flushAll()
|
|
77
|
+
assertDeepEq({root, a, b}, {root: 1, a: 1, b: 1}, 'deep child update reaches root, parent, and child')
|
|
78
|
+
|
|
79
|
+
s.a.d = 2
|
|
80
|
+
drain.flushAll()
|
|
81
|
+
assertDeepEq({root, a, b}, {root: 2, a: 2, b: 1}, 'sibling update does not notify nested child subscriber')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test(`${label}: whole-branch replacement preserves captured subscriptions`, () => {
|
|
85
|
+
const drain = createManualDrain()
|
|
86
|
+
const s = reactive<any>({branch: {leaf: {value: 1}, other: 1}}, drain.opts)
|
|
87
|
+
const branch = s.branch
|
|
88
|
+
const leaf = s.branch.leaf
|
|
89
|
+
let branchHits = 0, leafHits = 0
|
|
90
|
+
onUpdate(branch, () => branchHits++)
|
|
91
|
+
onUpdate(leaf, () => leafHits++)
|
|
92
|
+
|
|
93
|
+
s.branch = {leaf: {value: 2}, other: 9}
|
|
94
|
+
drain.flushAll()
|
|
95
|
+
assertEq(branch.valueOf(), branch, 'captured branch proxy remains usable')
|
|
96
|
+
assertEq(branch.leaf.value, 2, 'captured branch reads replacement target')
|
|
97
|
+
assertEq(leaf.value, 2, 'captured leaf reads replacement target')
|
|
98
|
+
assertDeepEq({branchHits, leafHits}, {branchHits: 1, leafHits: 1}, 'replacement notifies existing branch and leaf watchers')
|
|
99
|
+
|
|
100
|
+
leaf.value = 3
|
|
101
|
+
drain.flushAll()
|
|
102
|
+
assertEq(s.branch.leaf.value, 3, 'captured leaf writes through to replacement branch')
|
|
103
|
+
assertDeepEq({branchHits, leafHits}, {branchHits: 2, leafHits: 2}, 'post-replacement deep write still bubbles')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test(`${label}: delete detaches captured child proxies`, () => {
|
|
107
|
+
const drain = createManualDrain()
|
|
108
|
+
const manual = reactive<any>({child: {x: 1}}, drain.opts)
|
|
109
|
+
const oldChild = manual.child
|
|
110
|
+
let rootHits = 0, childHits = 0
|
|
111
|
+
onUpdate(manual, () => rootHits++)
|
|
112
|
+
onUpdate(oldChild, () => childHits++)
|
|
113
|
+
|
|
114
|
+
delete manual.child
|
|
115
|
+
drain.flushAll()
|
|
116
|
+
assertDeepEq({rootHits, childHits, hasChild: 'child' in manual}, {rootHits: 1, childHits: 1, hasChild: false}, 'delete notifies live root and deleted child once')
|
|
117
|
+
|
|
118
|
+
oldChild.x = 2
|
|
119
|
+
drain.flushAll()
|
|
120
|
+
assertDeepEq({rootHits, childHits}, {rootHits: 1, childHits: 1}, 'mutating detached child no longer bubbles')
|
|
121
|
+
assertThrows(() => onUpdate(oldChild, () => {}), /detached/, 'new subscription to detached proxy is rejected after delete')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test(`${label}: defineProperty handles configurable and non-configurable descriptors`, () => {
|
|
125
|
+
const drain = createManualDrain()
|
|
126
|
+
const s = reactive<any>({}, drain.opts)
|
|
127
|
+
let hits = 0
|
|
128
|
+
onUpdate(s, () => hits++)
|
|
129
|
+
|
|
130
|
+
Object.defineProperty(s, 'open', {value: {x: 1}, enumerable: true, configurable: true, writable: true})
|
|
131
|
+
const open = s.open
|
|
132
|
+
drain.flushAll()
|
|
133
|
+
assertEq(hits, 1, 'configurable defineProperty notifies once')
|
|
134
|
+
assertEq(open.x, 1, 'configurable object property is reactive on read')
|
|
135
|
+
|
|
136
|
+
Object.defineProperty(s, 'open', {value: {x: 2}, enumerable: true, configurable: true, writable: true})
|
|
137
|
+
drain.flushAll()
|
|
138
|
+
assertEq(open.x, 2, 'configurable descriptor replacement rebinds captured child')
|
|
139
|
+
|
|
140
|
+
Object.defineProperty(s, 'fixed', {value: 7, enumerable: true, configurable: false, writable: false})
|
|
141
|
+
drain.flushAll()
|
|
142
|
+
assertEq(hits, 3, 'non-configurable defineProperty notifies once')
|
|
143
|
+
assertDeepEq(Object.keys(s).sort(), ['fixed', 'open'], 'non-configurable key remains enumerable through proxy invariants')
|
|
144
|
+
assertThrows(() => { s.fixed = 8 }, /read only|trap returned falsish|Cannot assign/i, 'non-writable non-configurable property rejects writes')
|
|
145
|
+
drain.flushAll()
|
|
146
|
+
assertEq(s.fixed, 7, 'failed non-configurable write leaves value unchanged')
|
|
147
|
+
assertEq(hits, 3, 'failed non-configurable write does not notify')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test(`${label}: arrays are reactive containers and survive replacement`, () => {
|
|
151
|
+
const drain = createManualDrain()
|
|
152
|
+
const s = reactive<any>({items: [{v: 1}]}, drain.opts)
|
|
153
|
+
const arr = s.items
|
|
154
|
+
const first = s.items[0]
|
|
155
|
+
let rootHits = 0, arrHits = 0, firstHits = 0
|
|
156
|
+
onUpdate(s, () => rootHits++)
|
|
157
|
+
onUpdate(arr, () => arrHits++)
|
|
158
|
+
onUpdate(first, () => firstHits++)
|
|
159
|
+
|
|
160
|
+
s.items[0].v = 2
|
|
161
|
+
s.items.push({v: 3})
|
|
162
|
+
drain.flushAll()
|
|
163
|
+
assertDeepEq({rootHits, arrHits, firstHits}, {rootHits: 1, arrHits: 1, firstHits: 1}, 'index write and push coalesce')
|
|
164
|
+
assert(Array.isArray(s.items), 'wrapped array passes Array.isArray')
|
|
165
|
+
assertEq(JSON.stringify(s.items), '[{"v":2},{"v":3}]', 'wrapped array serializes as an array')
|
|
166
|
+
|
|
167
|
+
s.items = [{v: 4}]
|
|
168
|
+
drain.flushAll()
|
|
169
|
+
assertEq(arr[0].v, 4, 'captured array proxy follows replacement')
|
|
170
|
+
assertEq(first.v, 4, 'captured array element proxy follows replacement element')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test(`${label}: listenUpdate is cold until first listener and cold again after last off`, () => {
|
|
174
|
+
const drain = createManualDrain()
|
|
175
|
+
const s = reactive<any>({node: {x: 1}}, drain.opts)
|
|
176
|
+
const updates = listenUpdate(s.node)
|
|
177
|
+
let hits = 0
|
|
178
|
+
|
|
179
|
+
s.node.x = 2
|
|
180
|
+
assertEq(drain.pending, 0, 'listenUpdate without downstream listeners does not schedule')
|
|
181
|
+
|
|
182
|
+
const off = updates.on(() => hits++)
|
|
183
|
+
s.node.x = 3
|
|
184
|
+
assertEq(drain.pending, 1, 'first listener makes listenUpdate hot')
|
|
185
|
+
drain.flushAll()
|
|
186
|
+
assertEq(hits, 1, 'hot listenUpdate emits update')
|
|
187
|
+
|
|
188
|
+
off()
|
|
189
|
+
drain.clear()
|
|
190
|
+
s.node.x = 4
|
|
191
|
+
assertEq(drain.pending, 0, 'last off makes listenUpdate cold again')
|
|
192
|
+
drain.flushAll()
|
|
193
|
+
assertEq(hits, 1, 'cold listenUpdate no longer emits')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test(`${label}: duplicate onUpdate callbacks are independent subscriptions`, () => {
|
|
197
|
+
const drain = createManualDrain()
|
|
198
|
+
const s = reactive({x: 0}, drain.opts)
|
|
199
|
+
let hits = 0
|
|
200
|
+
const cb = () => hits++
|
|
201
|
+
const off1 = onUpdate(s, cb)
|
|
202
|
+
const off2 = onUpdate(s, cb)
|
|
203
|
+
|
|
204
|
+
off1()
|
|
205
|
+
s.x = 1
|
|
206
|
+
drain.flushAll()
|
|
207
|
+
assertEq(hits, 1, 'second duplicate subscription remains after first off')
|
|
208
|
+
|
|
209
|
+
off2()
|
|
210
|
+
s.x = 2
|
|
211
|
+
drain.flushAll()
|
|
212
|
+
assertEq(hits, 1, 'second off removes remaining duplicate subscription')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test(`${label}: flushReactive waits for batching and follow-up drains`, async () => {
|
|
216
|
+
const drain = createManualDrain()
|
|
217
|
+
const s = reactive({x: 0}, drain.opts)
|
|
218
|
+
let hits = 0
|
|
219
|
+
let resolved = false
|
|
220
|
+
onUpdate(s, () => {
|
|
221
|
+
hits++
|
|
222
|
+
if (hits === 1) s.x = 2
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
s.x = 1
|
|
226
|
+
const done = flushReactive(s).then(() => { resolved = true })
|
|
227
|
+
assertEq(hits, 0, 'callbacks do not run synchronously')
|
|
228
|
+
drain.flushOne()
|
|
229
|
+
assertEq(resolved, false, 'flushReactive waits for callback-triggered follow-up drain')
|
|
230
|
+
drain.flushAll()
|
|
231
|
+
await done
|
|
232
|
+
assertDeepEq({hits, value: s.x, resolved}, {hits: 2, value: 2, resolved: true}, 'flushReactive resolves after settled state')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test(`${label}: subscriber errors are isolated and rethrown asynchronously`, async () => {
|
|
236
|
+
const drain = createManualDrain()
|
|
237
|
+
const s = reactive({x: 0}, drain.opts)
|
|
238
|
+
let siblingHits = 0
|
|
239
|
+
let uncaught: any = null
|
|
240
|
+
const onUncaught = (e: any) => { uncaught = e }
|
|
241
|
+
process.once('uncaughtException', onUncaught)
|
|
242
|
+
|
|
243
|
+
onUpdate(s, () => { throw new Error(`${label} boom`) })
|
|
244
|
+
onUpdate(s, () => siblingHits++)
|
|
245
|
+
s.x = 1
|
|
246
|
+
const done = flushReactive(s)
|
|
247
|
+
drain.flushAll()
|
|
248
|
+
await done
|
|
249
|
+
await tick()
|
|
250
|
+
process.removeListener('uncaughtException', onUncaught)
|
|
251
|
+
|
|
252
|
+
assertEq(siblingHits, 1, 'sibling subscriber still runs after another subscriber throws')
|
|
253
|
+
assertEq(uncaught?.message, `${label} boom`, 'subscriber error is rethrown asynchronously')
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
addCoreSuite('src/Common/Observe/reactive', srcApi)
|
|
258
|
+
addCoreSuite('observe/reactive', observeApi)
|
|
259
|
+
|
|
260
|
+
async function main() {
|
|
261
|
+
for (const t of tests) {
|
|
262
|
+
try {
|
|
263
|
+
await t.run()
|
|
264
|
+
console.log(`OK ${t.name}`)
|
|
265
|
+
} catch (e: any) {
|
|
266
|
+
failures++
|
|
267
|
+
console.error(`FAIL ${t.name}`)
|
|
268
|
+
console.error(e?.stack ?? e)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
console.log(failures === 0 ? `ALL GREEN (${tests.length})` : `${failures} FAILURE(S) / ${tests.length}`)
|
|
272
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
main().catch(e => {
|
|
276
|
+
console.error(e?.stack ?? e)
|
|
277
|
+
process.exit(1)
|
|
278
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {existsSync} from 'node:fs'
|
|
2
|
+
import {createRequire} from 'node:module'
|
|
3
|
+
import {resolve} from 'node:path'
|
|
4
|
+
|
|
5
|
+
import * as srcIndex from '../../src/index'
|
|
6
|
+
import * as srcObserve from '../../src/Common/Observe/reactive'
|
|
7
|
+
|
|
8
|
+
type Api = {
|
|
9
|
+
reactive: Function
|
|
10
|
+
onUpdate: Function
|
|
11
|
+
flushReactive: Function
|
|
12
|
+
listenUpdate: Function
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let failures = 0
|
|
16
|
+
const tests: Array<{name: string; run: () => void | Promise<void>}> = []
|
|
17
|
+
const requireFromSpec = createRequire(__filename)
|
|
18
|
+
const rootDir = resolve(__dirname, '../..')
|
|
19
|
+
|
|
20
|
+
function test(name: string, run: () => void | Promise<void>) {
|
|
21
|
+
tests.push({name, run})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assert(cond: unknown, message: string) {
|
|
25
|
+
if (!cond) throw new Error(message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertEq<T>(actual: T, expected: T, message: string) {
|
|
29
|
+
if (!Object.is(actual, expected)) {
|
|
30
|
+
throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertApi(label: string, api: any): asserts api is Api {
|
|
35
|
+
assert(api, `${label} is missing`)
|
|
36
|
+
assertEq(typeof api.reactive, 'function', `${label}.reactive export`)
|
|
37
|
+
assertEq(typeof api.onUpdate, 'function', `${label}.onUpdate export`)
|
|
38
|
+
assertEq(typeof api.flushReactive, 'function', `${label}.flushReactive export`)
|
|
39
|
+
assertEq(typeof api.listenUpdate, 'function', `${label}.listenUpdate export`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function skip(name: string, reason: string) {
|
|
43
|
+
console.log(`SKIP ${name}: ${reason}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('src/Common/Observe/reactive direct import exposes Observe API', () => {
|
|
47
|
+
assertApi('src/Common/Observe/reactive', srcObserve)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('src/index exports Observe namespace', () => {
|
|
51
|
+
assertApi('src/index Observe', srcIndex.Observe)
|
|
52
|
+
assertEq(srcIndex.Observe.reactive, srcObserve.reactive, 'src/index Observe.reactive re-exports direct src module')
|
|
53
|
+
assertEq(srcIndex.Observe.onUpdate, srcObserve.onUpdate, 'src/index Observe.onUpdate re-exports direct src module')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("package.json exports './observe'", () => {
|
|
57
|
+
const packageJson = requireFromSpec(resolve(rootDir, 'package.json'))
|
|
58
|
+
assertEq(packageJson.exports?.['./observe'], './lib/Common/Observe/index.js', "package.json exports['./observe']")
|
|
59
|
+
|
|
60
|
+
const resolved = requireFromSpec.resolve('wenay-common2/observe')
|
|
61
|
+
assert(
|
|
62
|
+
resolved.replace(/\\/g, '/').endsWith('/lib/Common/Observe/index.js'),
|
|
63
|
+
`wenay-common2/observe resolved to unexpected path: ${resolved}`,
|
|
64
|
+
)
|
|
65
|
+
assertApi('wenay-common2/observe', requireFromSpec('wenay-common2/observe'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('lib root index exports Observe namespace', () => {
|
|
69
|
+
const libIndex = resolve(rootDir, 'lib/index.js')
|
|
70
|
+
if (!existsSync(libIndex)) {
|
|
71
|
+
skip('lib root index exports Observe namespace', 'lib/index.js is not present')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const libApi = requireFromSpec(libIndex)
|
|
76
|
+
assertApi('lib/index Observe', libApi.Observe)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('dist package artifacts include observe export when dist is present', () => {
|
|
80
|
+
const distDir = resolve(rootDir, 'dist')
|
|
81
|
+
if (!existsSync(distDir)) {
|
|
82
|
+
skip('dist package artifacts include observe export when dist is present', 'dist directory is not present')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const distPackageJsonPath = resolve(distDir, 'package.json')
|
|
87
|
+
const distObserveJs = resolve(distDir, 'lib/Common/Observe/reactive.js')
|
|
88
|
+
const distObserveDts = resolve(distDir, 'lib/Common/Observe/reactive.d.ts')
|
|
89
|
+
const distIndexJs = resolve(distDir, 'lib/index.js')
|
|
90
|
+
|
|
91
|
+
assert(existsSync(distPackageJsonPath), 'dist/package.json is present')
|
|
92
|
+
assert(existsSync(distObserveJs), 'dist observe JavaScript artifact is present')
|
|
93
|
+
assert(existsSync(distObserveDts), 'dist observe declaration artifact is present')
|
|
94
|
+
assert(existsSync(distIndexJs), 'dist lib/index.js artifact is present')
|
|
95
|
+
|
|
96
|
+
const distPackageJson = requireFromSpec(distPackageJsonPath)
|
|
97
|
+
assertEq(distPackageJson.exports?.['./observe'], './lib/Common/Observe/index.js', "dist package exports['./observe']")
|
|
98
|
+
assertApi('dist/lib/Common/Observe/reactive.js', requireFromSpec(distObserveJs))
|
|
99
|
+
assertApi('dist/lib/index.js Observe', requireFromSpec(distIndexJs).Observe)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
async function main() {
|
|
103
|
+
for (const t of tests) {
|
|
104
|
+
try {
|
|
105
|
+
await t.run()
|
|
106
|
+
console.log(`OK ${t.name}`)
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
failures++
|
|
109
|
+
console.error(`FAIL ${t.name}`)
|
|
110
|
+
console.error(e?.stack ?? e)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.log(failures === 0 ? `ALL GREEN (${tests.length})` : `${failures} FAILURE(S) / ${tests.length}`)
|
|
114
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main().catch(e => {
|
|
118
|
+
console.error(e?.stack ?? e)
|
|
119
|
+
process.exit(1)
|
|
120
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createRpcClient } from "../../src/Common/rcp/rpc-client"
|
|
2
|
+
import { createRpcServerAuto } from "../../src/Common/rcp/rpc-server-auto"
|
|
3
|
+
import { type SocketTmpl } from "../../src/Common/rcp/rpc-protocol"
|
|
4
|
+
import { listen as createListenPair } from "../../src/Common/events/Listen"
|
|
5
|
+
|
|
6
|
+
function createLoopback(): [SocketTmpl, SocketTmpl] {
|
|
7
|
+
const a: Record<string, ((d: any) => void)[]> = {}
|
|
8
|
+
const b: Record<string, ((d: any) => void)[]> = {}
|
|
9
|
+
const make = (mine: typeof a, theirs: typeof a): SocketTmpl => ({
|
|
10
|
+
on: (e, cb) => { (mine[e] ??= []).push(cb) },
|
|
11
|
+
emit: (e, d) => {
|
|
12
|
+
const wire = d === undefined ? undefined : JSON.parse(JSON.stringify(d))
|
|
13
|
+
for (const cb of (theirs[e] ?? [])) queueMicrotask(() => cb(wire))
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
return [make(a, b), make(b, a)]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const delay = (ms = 0) => new Promise<void>(r => setTimeout(r, ms))
|
|
20
|
+
|
|
21
|
+
function eq(a: any, b: any): boolean {
|
|
22
|
+
if (a === b) return true
|
|
23
|
+
if (typeof a === "bigint" || typeof b === "bigint") return a === b
|
|
24
|
+
if (a instanceof Date && b instanceof Date) return a.valueOf() === b.valueOf()
|
|
25
|
+
if (a instanceof Map && b instanceof Map) {
|
|
26
|
+
if (a.size !== b.size) return false
|
|
27
|
+
for (const [k, v] of a) if (!eq(v, b.get(k))) return false
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
if (a instanceof Set && b instanceof Set) {
|
|
31
|
+
if (a.size !== b.size) return false
|
|
32
|
+
for (const v of a) if (![...b].some(x => eq(x, v))) return false
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => eq(v, b[i]))
|
|
36
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
37
|
+
const ak = Object.keys(a), bk = Object.keys(b)
|
|
38
|
+
return ak.length === bk.length && ak.every(k => eq(a[k], b[k]))
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fmt(v: any): string {
|
|
44
|
+
return typeof v === "bigint" ? `${v}n` : JSON.stringify(v)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeChecker() {
|
|
48
|
+
let fails = 0
|
|
49
|
+
async function check<T>(name: string, run: () => T | Promise<T>, exp: T) {
|
|
50
|
+
try {
|
|
51
|
+
const got = await run()
|
|
52
|
+
const ok = eq(got, exp)
|
|
53
|
+
if (!ok) fails++
|
|
54
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${name} got=${fmt(got)} exp=${fmt(exp)}`)
|
|
55
|
+
} catch (e: any) {
|
|
56
|
+
fails++
|
|
57
|
+
console.log(`FAIL ${name} threw=${e?.message ?? e}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { check, done: () => fails }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nextUncaught(ms = 200) {
|
|
64
|
+
return new Promise<string | null>(resolve => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
process.removeListener("uncaughtException", onError)
|
|
67
|
+
resolve(null)
|
|
68
|
+
}, ms)
|
|
69
|
+
const onError = (e: any) => {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
resolve(e?.message ?? String(e))
|
|
72
|
+
}
|
|
73
|
+
process.once("uncaughtException", onError)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeRpcListen() {
|
|
78
|
+
const [emit, stream] = createListenPair<number>()
|
|
79
|
+
const obj = { stream }
|
|
80
|
+
const [clientSocket, serverSocket] = createLoopback()
|
|
81
|
+
const client = createRpcClient<typeof obj>({ socket: clientSocket, socketKey: "rpc" })
|
|
82
|
+
const server = createRpcServerAuto({ socket: serverSocket, object: obj, socketKey: "rpc" })
|
|
83
|
+
return { emit, client, server, api: client.func as any }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
const { check, done } = makeChecker()
|
|
88
|
+
const watchdog = setTimeout(() => {
|
|
89
|
+
console.error("WATCHDOG timeout")
|
|
90
|
+
process.exit(3)
|
|
91
|
+
}, 10000)
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
const { emit, client, server, api } = makeRpcListen()
|
|
95
|
+
await delay(0)
|
|
96
|
+
const got: string[] = []
|
|
97
|
+
api.stream.callback((v: number) => { got.push(`first:${v}`); throw new Error("first consumer boom") })
|
|
98
|
+
api.stream.callback((v: number) => got.push(`second:${v}`))
|
|
99
|
+
await delay(10)
|
|
100
|
+
|
|
101
|
+
const thrown = nextUncaught()
|
|
102
|
+
emit(3)
|
|
103
|
+
await delay(20)
|
|
104
|
+
|
|
105
|
+
await check("fan-out isolation: both consumers ran", () => got, ["first:3", "second:3"])
|
|
106
|
+
await check("fan-out isolation: first throw surfaced async", () => thrown, "first consumer boom")
|
|
107
|
+
await check("fan-out isolation: one wire subscription", () => client.api.subscriptions()[0]?.consumers, 2)
|
|
108
|
+
await check("fan-out isolation: server has one consumer", () => server.api.subscriptions()[0]?.consumers, 1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
const { emit, client, server, api } = makeRpcListen()
|
|
113
|
+
await delay(0)
|
|
114
|
+
const a: number[] = [], b: number[] = []
|
|
115
|
+
const richA = { id: 1n, tags: new Set(["a", "b"]), meta: new Map<any, any>([["x", 1]]) }
|
|
116
|
+
const richB = { id: 2n, tags: new Set(["a", "c"]), meta: new Map<any, any>([["x", 2]]) }
|
|
117
|
+
|
|
118
|
+
let subscribeError: any = null
|
|
119
|
+
try {
|
|
120
|
+
api.stream.callback((v: number) => a.push(v), richA)
|
|
121
|
+
api.stream.callback((v: number) => b.push(v), richB)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
subscribeError = e
|
|
124
|
+
}
|
|
125
|
+
await delay(20)
|
|
126
|
+
emit(8)
|
|
127
|
+
await delay(20)
|
|
128
|
+
|
|
129
|
+
await check("rich dedupe key: subscribe did not throw", () => subscribeError?.message ?? null, null)
|
|
130
|
+
await check("rich dedupe key: distinct rich args do not collide client-side", () => client.api.subscriptions().length, 2)
|
|
131
|
+
await check("rich dedupe key: distinct rich args do not collide server-side", () => server.api.subscriptions()[0]?.consumers, 2)
|
|
132
|
+
await check("rich dedupe key: first rich subscription receives", () => a, [8])
|
|
133
|
+
await check("rich dedupe key: second rich subscription receives", () => b, [8])
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
const { emit, client, server, api } = makeRpcListen()
|
|
138
|
+
await delay(0)
|
|
139
|
+
const got: number[] = []
|
|
140
|
+
const cb = (v: number) => got.push(v)
|
|
141
|
+
const off1 = api.stream.callback(cb)
|
|
142
|
+
const off2 = api.stream.callback(cb)
|
|
143
|
+
await delay(20)
|
|
144
|
+
|
|
145
|
+
emit(1)
|
|
146
|
+
await delay(20)
|
|
147
|
+
off1()
|
|
148
|
+
await delay(20)
|
|
149
|
+
const afterFirstOff = {
|
|
150
|
+
clientConsumers: client.api.subscriptions()[0]?.consumers,
|
|
151
|
+
serverConsumers: server.api.subscriptions()[0]?.consumers,
|
|
152
|
+
}
|
|
153
|
+
emit(2)
|
|
154
|
+
await delay(20)
|
|
155
|
+
off2()
|
|
156
|
+
await delay(30)
|
|
157
|
+
|
|
158
|
+
await check("duplicate same callback: local duplicate fan-out before off", () => got, [1, 1, 2])
|
|
159
|
+
await check("duplicate same callback: one local consumer remains after first off", () => afterFirstOff.clientConsumers, 1)
|
|
160
|
+
await check("duplicate same callback: server still has one wire consumer after first off", () => afterFirstOff.serverConsumers, 1)
|
|
161
|
+
await check("duplicate same callback: client cleaned after last off", () => client.api.subscriptions().length, 0)
|
|
162
|
+
await check("duplicate same callback: server cleaned after last off", () => server.api.subscriptions().length, 0)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
const { emit, client, server, api } = makeRpcListen()
|
|
167
|
+
await delay(0)
|
|
168
|
+
const a: number[] = [], b: number[] = []
|
|
169
|
+
const offA = api.stream.callback((v: number) => a.push(v))
|
|
170
|
+
api.stream.callback((v: number) => b.push(v))
|
|
171
|
+
await delay(20)
|
|
172
|
+
|
|
173
|
+
emit(5)
|
|
174
|
+
await delay(20)
|
|
175
|
+
offA()
|
|
176
|
+
await delay(20)
|
|
177
|
+
emit(6)
|
|
178
|
+
await delay(20)
|
|
179
|
+
|
|
180
|
+
await check("unsub one consumer: removed consumer stopped", () => a, [5])
|
|
181
|
+
await check("unsub one consumer: other consumer kept receiving", () => b, [5, 6])
|
|
182
|
+
await check("unsub one consumer: client still has shared wire sub", () => client.api.subscriptions()[0]?.consumers, 1)
|
|
183
|
+
await check("unsub one consumer: server wire consumer still alive", () => server.api.subscriptions()[0]?.consumers, 1)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clearTimeout(watchdog)
|
|
187
|
+
const fails = done()
|
|
188
|
+
console.log(fails === 0 ? "ALL GREEN" : `${fails} FAILURE(S)`)
|
|
189
|
+
process.exit(fails === 0 ? 0 : 1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
main().catch(e => {
|
|
193
|
+
console.error(e)
|
|
194
|
+
process.exit(2)
|
|
195
|
+
})
|