wenay-common2 1.0.69 → 1.0.73

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