@dtudury/streamo 1.0.1 → 3.0.0
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 +25 -17
- package/index.js +32 -0
- package/package.json +16 -2
- package/public/apps/explorer/index.html +118 -0
- package/public/apps/explorer/main.js +434 -0
- package/public/index.html +7 -0
- package/public/streamo/Signer.js +14 -11
- package/public/streamo/Streamo.js +21 -3
- package/public/streamo/registrySync.js +12 -0
- package/public/streamo/utils/Recaller.js +13 -5
- package/.claude/settings.json +0 -126
- package/.claude/settings.local.json +0 -15
- package/.env.dev +0 -4
- package/CLAUDE.md +0 -73
- package/ROADMAP.md +0 -199
- package/jsconfig.json +0 -10
- package/public/streamo/Repo.test.js +0 -82
- package/public/streamo/RepoRegistry.test.js +0 -87
- package/public/streamo/Streamo.test.js +0 -205
- package/public/streamo/h.mount.test.js +0 -67
- package/public/streamo/h.test.js +0 -121
- package/public/streamo/registrySync.test.js +0 -373
- package/public/streamo/sync.test.js +0 -144
- package/public/streamo/utils/mockDOM.js +0 -113
- package/public/streamo/utils/testing.js +0 -90
- package/smoke.test.js +0 -132
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer } from 'ws'
|
|
2
|
-
import { describe } from './utils/testing.js'
|
|
3
|
-
import { RepoRegistry } from './RepoRegistry.js'
|
|
4
|
-
import { attachStreamSync } from './outletSync.js'
|
|
5
|
-
import { registrySync } from './registrySync.js'
|
|
6
|
-
|
|
7
|
-
// makeVerifiedWritableStream only rejects invalid SIGNATURE chunks —
|
|
8
|
-
// plain data chunks pass through even with fake keys. 33 bytes = compressed pubkey size.
|
|
9
|
-
const fakeKey = (n = 0) => '02' + n.toString(16).padStart(2, '0').repeat(32) // 66-char hex
|
|
10
|
-
|
|
11
|
-
/** Wait up to `ms` ms for `fn()` to return truthy, polling every 10 ms. */
|
|
12
|
-
function waitFor (fn, ms = 500) {
|
|
13
|
-
return new Promise((resolve, reject) => {
|
|
14
|
-
const start = Date.now()
|
|
15
|
-
const poll = () => {
|
|
16
|
-
const v = fn()
|
|
17
|
-
if (v) return resolve(v)
|
|
18
|
-
if (Date.now() - start > ms) return reject(new Error('waitFor timeout'))
|
|
19
|
-
setTimeout(poll, 10)
|
|
20
|
-
}
|
|
21
|
-
poll()
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Start a WebSocketServer on a random port backed by a registry. */
|
|
26
|
-
function startServer (registry) {
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
const wss = new WebSocketServer({ port: 0 })
|
|
29
|
-
wss.on('listening', () => {
|
|
30
|
-
const { port } = wss.address()
|
|
31
|
-
attachStreamSync(wss, registry, 'test-outlet')
|
|
32
|
-
resolve({ wss, port })
|
|
33
|
-
})
|
|
34
|
-
wss.on('error', reject)
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe(import.meta.url, ({ test }) => {
|
|
39
|
-
test('onOpen fires after registry.open resolves', async ({ assert }) => {
|
|
40
|
-
const registry = new RepoRegistry()
|
|
41
|
-
const calls = []
|
|
42
|
-
registry.onOpen((key, repo) => calls.push({ key, repo }))
|
|
43
|
-
const repo = await registry.open('abc')
|
|
44
|
-
assert.equal(calls.length, 1)
|
|
45
|
-
assert.equal(calls[0].key, 'abc')
|
|
46
|
-
assert.ok(calls[0].repo === repo)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('offOpen removes the callback', async ({ assert }) => {
|
|
50
|
-
const registry = new RepoRegistry()
|
|
51
|
-
let count = 0
|
|
52
|
-
const cb = () => count++
|
|
53
|
-
registry.onOpen(cb)
|
|
54
|
-
await registry.open('x')
|
|
55
|
-
registry.offOpen(cb)
|
|
56
|
-
await registry.open('y')
|
|
57
|
-
assert.equal(count, 1)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
test('onOpen not called for already-open key (concurrent open)', async ({ assert }) => {
|
|
61
|
-
const registry = new RepoRegistry()
|
|
62
|
-
let count = 0
|
|
63
|
-
registry.onOpen(() => count++)
|
|
64
|
-
await Promise.all([registry.open('k'), registry.open('k'), registry.open('k')])
|
|
65
|
-
assert.equal(count, 1)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('two registries sync an existing repo via registrySync', async ({ assert }) => {
|
|
69
|
-
const serverRegistry = new RepoRegistry()
|
|
70
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
71
|
-
|
|
72
|
-
const keyHex = fakeKey(1)
|
|
73
|
-
const serverRepo = await serverRegistry.open(keyHex)
|
|
74
|
-
serverRepo.set({ hello: 'world' })
|
|
75
|
-
|
|
76
|
-
const clientRegistry = new RepoRegistry()
|
|
77
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port, { filter: k => k === keyHex })
|
|
78
|
-
|
|
79
|
-
await waitFor(() => clientRegistry.get(keyHex)?.get('hello') === 'world')
|
|
80
|
-
assert.equal(clientRegistry.get(keyHex).get('hello'), 'world')
|
|
81
|
-
|
|
82
|
-
ws.close()
|
|
83
|
-
await new Promise(r => wss.close(r))
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('changes on server after connect are synced to client', async ({ assert }) => {
|
|
87
|
-
const serverRegistry = new RepoRegistry()
|
|
88
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
89
|
-
|
|
90
|
-
const keyHex = fakeKey(2)
|
|
91
|
-
const serverRepo = await serverRegistry.open(keyHex)
|
|
92
|
-
serverRepo.set({ v: 1 })
|
|
93
|
-
|
|
94
|
-
const clientRegistry = new RepoRegistry()
|
|
95
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port, { filter: k => k === keyHex })
|
|
96
|
-
|
|
97
|
-
await waitFor(() => clientRegistry.get(keyHex)?.get('v') === 1)
|
|
98
|
-
|
|
99
|
-
serverRepo.set({ v: 2 })
|
|
100
|
-
await waitFor(() => clientRegistry.get(keyHex)?.get('v') === 2)
|
|
101
|
-
assert.equal(clientRegistry.get(keyHex).get('v'), 2)
|
|
102
|
-
|
|
103
|
-
ws.close()
|
|
104
|
-
await new Promise(r => wss.close(r))
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('newly opened server repos are announced and synced', async ({ assert }) => {
|
|
108
|
-
const serverRegistry = new RepoRegistry()
|
|
109
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
110
|
-
|
|
111
|
-
const keyHex = fakeKey(3)
|
|
112
|
-
const clientRegistry = new RepoRegistry()
|
|
113
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port)
|
|
114
|
-
|
|
115
|
-
const serverRepo = await serverRegistry.open(keyHex)
|
|
116
|
-
serverRepo.set({ late: true })
|
|
117
|
-
|
|
118
|
-
await waitFor(() => clientRegistry.get(keyHex)?.get('late') === true)
|
|
119
|
-
assert.equal(clientRegistry.get(keyHex).get('late'), true)
|
|
120
|
-
|
|
121
|
-
ws.close()
|
|
122
|
-
await new Promise(r => wss.close(r))
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
test('filter prevents unwanted repos from syncing', async ({ assert }) => {
|
|
126
|
-
const serverRegistry = new RepoRegistry()
|
|
127
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
128
|
-
|
|
129
|
-
const keyA = fakeKey(4)
|
|
130
|
-
const keyB = fakeKey(5)
|
|
131
|
-
|
|
132
|
-
const repoA = await serverRegistry.open(keyA)
|
|
133
|
-
repoA.set({ name: 'a' })
|
|
134
|
-
const repoB = await serverRegistry.open(keyB)
|
|
135
|
-
repoB.set({ name: 'b' })
|
|
136
|
-
|
|
137
|
-
const clientRegistry = new RepoRegistry()
|
|
138
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port, { filter: k => k === keyA })
|
|
139
|
-
|
|
140
|
-
await waitFor(() => clientRegistry.get(keyA)?.get('name') === 'a')
|
|
141
|
-
assert.equal(clientRegistry.get(keyA).get('name'), 'a')
|
|
142
|
-
|
|
143
|
-
await new Promise(r => setTimeout(r, 100))
|
|
144
|
-
assert.equal(clientRegistry.get(keyB), undefined, 'keyB was filtered out')
|
|
145
|
-
|
|
146
|
-
ws.close()
|
|
147
|
-
await new Promise(r => wss.close(r))
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
test('two peers with different repos each sync both after connecting', async ({ assert }) => {
|
|
151
|
-
const registryA = new RepoRegistry()
|
|
152
|
-
const registryB = new RepoRegistry()
|
|
153
|
-
|
|
154
|
-
const keyA = fakeKey(6)
|
|
155
|
-
const keyB = fakeKey(7)
|
|
156
|
-
|
|
157
|
-
const repoA = await registryA.open(keyA)
|
|
158
|
-
repoA.set({ owner: 'A' })
|
|
159
|
-
const repoB = await registryB.open(keyB)
|
|
160
|
-
repoB.set({ owner: 'B' })
|
|
161
|
-
|
|
162
|
-
const { wss, port } = await startServer(registryA)
|
|
163
|
-
const { ws } = await registrySync(registryB, 'localhost', port)
|
|
164
|
-
|
|
165
|
-
await waitFor(() => registryA.get(keyB)?.get('owner') === 'B')
|
|
166
|
-
await waitFor(() => registryB.get(keyA)?.get('owner') === 'A')
|
|
167
|
-
|
|
168
|
-
assert.equal(registryA.get(keyB).get('owner'), 'B')
|
|
169
|
-
assert.equal(registryB.get(keyA).get('owner'), 'A')
|
|
170
|
-
|
|
171
|
-
ws.close()
|
|
172
|
-
await new Promise(r => wss.close(r))
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
test('follow: auto-subscribes to repos referenced in a synced repo\'s value', async ({ assert }) => {
|
|
176
|
-
// Simulates a chat app: rootRepo lists participant keys; client follows the
|
|
177
|
-
// root and should automatically discover and sync all participant repos.
|
|
178
|
-
const serverRegistry = new RepoRegistry()
|
|
179
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
180
|
-
|
|
181
|
-
const rootKey = fakeKey(10)
|
|
182
|
-
const aliceKey = fakeKey(11)
|
|
183
|
-
const bobKey = fakeKey(12)
|
|
184
|
-
|
|
185
|
-
const rootRepo = await serverRegistry.open(rootKey)
|
|
186
|
-
const aliceRepo = await serverRegistry.open(aliceKey)
|
|
187
|
-
const bobRepo = await serverRegistry.open(bobKey)
|
|
188
|
-
|
|
189
|
-
aliceRepo.set({ name: 'alice', message: 'hello' })
|
|
190
|
-
bobRepo.set({ name: 'bob', message: 'hey' })
|
|
191
|
-
rootRepo.set({ members: [aliceKey, bobKey] })
|
|
192
|
-
|
|
193
|
-
const clientRegistry = new RepoRegistry()
|
|
194
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port, {
|
|
195
|
-
filter: k => k === rootKey, // only explicitly subscribe to root
|
|
196
|
-
follow: (keyHex, repo, subscribe) => {
|
|
197
|
-
// extract participant keys from the chat repo
|
|
198
|
-
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
199
|
-
}
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
// Root syncs via filter; participants sync via follow
|
|
203
|
-
await waitFor(() => clientRegistry.get(aliceKey)?.get('name') === 'alice')
|
|
204
|
-
await waitFor(() => clientRegistry.get(bobKey)?.get('name') === 'bob')
|
|
205
|
-
|
|
206
|
-
assert.equal(clientRegistry.get(aliceKey).get('name'), 'alice')
|
|
207
|
-
assert.equal(clientRegistry.get(bobKey).get('name'), 'bob')
|
|
208
|
-
|
|
209
|
-
ws.close()
|
|
210
|
-
await new Promise(r => wss.close(r))
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('follow: re-runs when a repo changes and discovers newly added refs', async ({ assert }) => {
|
|
214
|
-
const serverRegistry = new RepoRegistry()
|
|
215
|
-
const { wss, port } = await startServer(serverRegistry)
|
|
216
|
-
|
|
217
|
-
const rootKey = fakeKey(13)
|
|
218
|
-
const carolKey = fakeKey(14)
|
|
219
|
-
|
|
220
|
-
const rootRepo = await serverRegistry.open(rootKey)
|
|
221
|
-
rootRepo.set({ members: [] }) // starts empty
|
|
222
|
-
|
|
223
|
-
const clientRegistry = new RepoRegistry()
|
|
224
|
-
const { ws } = await registrySync(clientRegistry, 'localhost', port, {
|
|
225
|
-
filter: k => k === rootKey,
|
|
226
|
-
follow: (keyHex, repo, subscribe) => {
|
|
227
|
-
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
228
|
-
}
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
await waitFor(() => clientRegistry.get(rootKey)?.get('members') !== undefined)
|
|
232
|
-
|
|
233
|
-
// Carol joins: her repo is added to the server, root is updated to list her
|
|
234
|
-
const carolRepo = await serverRegistry.open(carolKey)
|
|
235
|
-
carolRepo.set({ name: 'carol' })
|
|
236
|
-
rootRepo.set({ members: [carolKey] })
|
|
237
|
-
|
|
238
|
-
await waitFor(() => clientRegistry.get(carolKey)?.get('name') === 'carol')
|
|
239
|
-
assert.equal(clientRegistry.get(carolKey).get('name'), 'carol')
|
|
240
|
-
|
|
241
|
-
ws.close()
|
|
242
|
-
await new Promise(r => wss.close(r))
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
test('announce is routed to interested peers', async ({ assert }) => {
|
|
246
|
-
const { wss, port } = await startServer(new RepoRegistry())
|
|
247
|
-
const topic = fakeKey(20)
|
|
248
|
-
const announced = fakeKey(21)
|
|
249
|
-
|
|
250
|
-
const received = []
|
|
251
|
-
const sessionA = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
252
|
-
onAnnounce: (key, t) => received.push({ key, topic: t })
|
|
253
|
-
})
|
|
254
|
-
sessionA.interest(topic)
|
|
255
|
-
|
|
256
|
-
const sessionB = await registrySync(new RepoRegistry(), 'localhost', port)
|
|
257
|
-
|
|
258
|
-
// Give the interest message time to reach the server
|
|
259
|
-
await new Promise(r => setTimeout(r, 50))
|
|
260
|
-
sessionB.announce(announced, topic)
|
|
261
|
-
|
|
262
|
-
await waitFor(() => received.length === 1)
|
|
263
|
-
assert.equal(received[0].key, announced)
|
|
264
|
-
assert.equal(received[0].topic, topic)
|
|
265
|
-
|
|
266
|
-
sessionA.close()
|
|
267
|
-
sessionB.close()
|
|
268
|
-
await new Promise(r => wss.close(r))
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
test('announce is not received without interest', async ({ assert }) => {
|
|
272
|
-
const { wss, port } = await startServer(new RepoRegistry())
|
|
273
|
-
const topic = fakeKey(22)
|
|
274
|
-
const announced = fakeKey(23)
|
|
275
|
-
|
|
276
|
-
const received = []
|
|
277
|
-
const sessionA = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
278
|
-
onAnnounce: (key) => received.push(key)
|
|
279
|
-
})
|
|
280
|
-
// sessionA does NOT call interest(topic)
|
|
281
|
-
|
|
282
|
-
const sessionB = await registrySync(new RepoRegistry(), 'localhost', port)
|
|
283
|
-
await new Promise(r => setTimeout(r, 50))
|
|
284
|
-
sessionB.announce(announced, topic)
|
|
285
|
-
|
|
286
|
-
await new Promise(r => setTimeout(r, 100))
|
|
287
|
-
assert.equal(received.length, 0, 'no announcements without interest')
|
|
288
|
-
|
|
289
|
-
sessionA.close()
|
|
290
|
-
sessionB.close()
|
|
291
|
-
await new Promise(r => wss.close(r))
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
test('announce reaches multiple interested peers', async ({ assert }) => {
|
|
295
|
-
const { wss, port } = await startServer(new RepoRegistry())
|
|
296
|
-
const topic = fakeKey(24)
|
|
297
|
-
const announced = fakeKey(25)
|
|
298
|
-
|
|
299
|
-
const receivedA = [], receivedB = []
|
|
300
|
-
const sessionA = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
301
|
-
onAnnounce: (key) => receivedA.push(key)
|
|
302
|
-
})
|
|
303
|
-
const sessionB = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
304
|
-
onAnnounce: (key) => receivedB.push(key)
|
|
305
|
-
})
|
|
306
|
-
sessionA.interest(topic)
|
|
307
|
-
sessionB.interest(topic)
|
|
308
|
-
|
|
309
|
-
const sessionC = await registrySync(new RepoRegistry(), 'localhost', port)
|
|
310
|
-
await new Promise(r => setTimeout(r, 50))
|
|
311
|
-
sessionC.announce(announced, topic)
|
|
312
|
-
|
|
313
|
-
await waitFor(() => receivedA.length === 1 && receivedB.length === 1)
|
|
314
|
-
assert.equal(receivedA[0], announced)
|
|
315
|
-
assert.equal(receivedB[0], announced)
|
|
316
|
-
|
|
317
|
-
sessionA.close()
|
|
318
|
-
sessionB.close()
|
|
319
|
-
sessionC.close()
|
|
320
|
-
await new Promise(r => wss.close(r))
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
test('announce is not echoed back to the sender', async ({ assert }) => {
|
|
324
|
-
const { wss, port } = await startServer(new RepoRegistry())
|
|
325
|
-
const topic = fakeKey(26)
|
|
326
|
-
const announced = fakeKey(27)
|
|
327
|
-
|
|
328
|
-
const received = []
|
|
329
|
-
const session = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
330
|
-
onAnnounce: (key) => received.push(key)
|
|
331
|
-
})
|
|
332
|
-
session.interest(topic)
|
|
333
|
-
|
|
334
|
-
await new Promise(r => setTimeout(r, 50))
|
|
335
|
-
session.announce(announced, topic) // sender declares interest and announces
|
|
336
|
-
|
|
337
|
-
await new Promise(r => setTimeout(r, 100))
|
|
338
|
-
assert.equal(received.length, 0, 'sender should not receive its own announcement')
|
|
339
|
-
|
|
340
|
-
session.close()
|
|
341
|
-
await new Promise(r => wss.close(r))
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
test('after disconnect, interest is cleaned up and announcements stop', async ({ assert }) => {
|
|
345
|
-
const { wss, port } = await startServer(new RepoRegistry())
|
|
346
|
-
const topic = fakeKey(28)
|
|
347
|
-
const announced = fakeKey(29)
|
|
348
|
-
|
|
349
|
-
const received = []
|
|
350
|
-
const sessionA = await registrySync(new RepoRegistry(), 'localhost', port, {
|
|
351
|
-
onAnnounce: (key) => received.push(key)
|
|
352
|
-
})
|
|
353
|
-
sessionA.interest(topic)
|
|
354
|
-
|
|
355
|
-
const sessionB = await registrySync(new RepoRegistry(), 'localhost', port)
|
|
356
|
-
|
|
357
|
-
// Confirm routing works before disconnect
|
|
358
|
-
await new Promise(r => setTimeout(r, 50))
|
|
359
|
-
sessionB.announce(announced, topic)
|
|
360
|
-
await waitFor(() => received.length === 1)
|
|
361
|
-
|
|
362
|
-
// Disconnect A, then announce again
|
|
363
|
-
sessionA.close()
|
|
364
|
-
await new Promise(r => setTimeout(r, 50))
|
|
365
|
-
sessionB.announce(announced, topic)
|
|
366
|
-
|
|
367
|
-
await new Promise(r => setTimeout(r, 100))
|
|
368
|
-
assert.equal(received.length, 1, 'no more announcements after disconnect')
|
|
369
|
-
|
|
370
|
-
sessionB.close()
|
|
371
|
-
await new Promise(r => wss.close(r))
|
|
372
|
-
})
|
|
373
|
-
})
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { describe } from './utils/testing.js'
|
|
2
|
-
import { RepoRegistry as StreamRegistry } from './RepoRegistry.js'
|
|
3
|
-
import { outletSync } from './outletSync.js'
|
|
4
|
-
import { originSync } from './originSync.js'
|
|
5
|
-
|
|
6
|
-
const KEY = 'aabbccddeeff0011'
|
|
7
|
-
|
|
8
|
-
// Wait until predicate(stream) returns true.
|
|
9
|
-
// Accesses stream.byteLength explicitly so the watcher re-runs on every append,
|
|
10
|
-
// regardless of whether the predicate itself touches a reactive path.
|
|
11
|
-
function waitFor (stream, predicate, timeout = 2000) {
|
|
12
|
-
return new Promise((resolve, reject) => {
|
|
13
|
-
const t = setTimeout(() => reject(new Error('timeout')), timeout)
|
|
14
|
-
let done = false
|
|
15
|
-
stream.watch('waitFor', () => {
|
|
16
|
-
if (!done && predicate(stream)) {
|
|
17
|
-
done = true
|
|
18
|
-
clearTimeout(t)
|
|
19
|
-
resolve()
|
|
20
|
-
}
|
|
21
|
-
})
|
|
22
|
-
})
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe(import.meta.url, ({ test }) => {
|
|
26
|
-
test('outlet syncs existing stream data to a new origin', async ({ assert }) => {
|
|
27
|
-
const serverRegistry = new StreamRegistry()
|
|
28
|
-
const serverStream = await serverRegistry.open(KEY)
|
|
29
|
-
serverStream.set({ hello: 'world' })
|
|
30
|
-
|
|
31
|
-
const wss = outletSync(serverRegistry, 0)
|
|
32
|
-
await new Promise(resolve => wss.on('listening', resolve))
|
|
33
|
-
const { port } = wss.address()
|
|
34
|
-
|
|
35
|
-
const clientRegistry = new StreamRegistry()
|
|
36
|
-
const clientStream = await clientRegistry.open(KEY)
|
|
37
|
-
const ws = await originSync(clientStream, KEY, 'localhost', port)
|
|
38
|
-
|
|
39
|
-
await waitFor(clientStream, s => s.get('hello') === 'world')
|
|
40
|
-
assert.equal(clientStream.get('hello'), 'world', 'client received server data')
|
|
41
|
-
|
|
42
|
-
ws.close()
|
|
43
|
-
for (const c of wss.clients) c.terminate()
|
|
44
|
-
wss.close()
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('origin syncs local data up to the outlet', async ({ assert }) => {
|
|
48
|
-
const serverRegistry = new StreamRegistry()
|
|
49
|
-
const wss = outletSync(serverRegistry, 0)
|
|
50
|
-
await new Promise(resolve => wss.on('listening', resolve))
|
|
51
|
-
const { port } = wss.address()
|
|
52
|
-
|
|
53
|
-
const clientRegistry = new StreamRegistry()
|
|
54
|
-
const clientStream = await clientRegistry.open(KEY)
|
|
55
|
-
clientStream.set({ from: 'client' })
|
|
56
|
-
|
|
57
|
-
const ws = await originSync(clientStream, KEY, 'localhost', port)
|
|
58
|
-
const serverStream = await serverRegistry.open(KEY)
|
|
59
|
-
|
|
60
|
-
await waitFor(serverStream, s => s.get('from') === 'client')
|
|
61
|
-
assert.equal(serverStream.get('from'), 'client', 'server received client data')
|
|
62
|
-
|
|
63
|
-
ws.close()
|
|
64
|
-
for (const c of wss.clients) c.terminate()
|
|
65
|
-
wss.close()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('two origins converge on the same byte stream', async ({ assert }) => {
|
|
69
|
-
const serverRegistry = new StreamRegistry()
|
|
70
|
-
const wss = outletSync(serverRegistry, 0)
|
|
71
|
-
await new Promise(resolve => wss.on('listening', resolve))
|
|
72
|
-
const { port } = wss.address()
|
|
73
|
-
|
|
74
|
-
const r1 = new StreamRegistry()
|
|
75
|
-
const r2 = new StreamRegistry()
|
|
76
|
-
const s1 = await r1.open(KEY)
|
|
77
|
-
const s2 = await r2.open(KEY)
|
|
78
|
-
|
|
79
|
-
s1.set({ x: 1 })
|
|
80
|
-
s2.set({ x: 2 })
|
|
81
|
-
|
|
82
|
-
// NOTE: these are bare Streamos, not Repos — no commit records, no parent
|
|
83
|
-
// pointers. The two streams have conflicting chunks at the same byte
|
|
84
|
-
// offsets; both arrive at the server and each other via dedup-append.
|
|
85
|
-
// byteLength convergence is all we can assert here: the merged stream
|
|
86
|
-
// contains all unique chunks from both writers but the second writer's
|
|
87
|
-
// value address is no longer valid in the merged layout. This is a known
|
|
88
|
-
// limitation; see ROADMAP "multi-device write conflict detection".
|
|
89
|
-
const ws1 = await originSync(s1, KEY, 'localhost', port)
|
|
90
|
-
const ws2 = await originSync(s2, KEY, 'localhost', port)
|
|
91
|
-
|
|
92
|
-
const serverStream = await serverRegistry.open(KEY)
|
|
93
|
-
await waitFor(serverStream, s => s.byteLength >= s1.byteLength && s.byteLength >= s2.byteLength)
|
|
94
|
-
await waitFor(s1, s => s.byteLength >= serverStream.byteLength)
|
|
95
|
-
await waitFor(s2, s => s.byteLength >= serverStream.byteLength)
|
|
96
|
-
|
|
97
|
-
assert.equal(s1.byteLength, s2.byteLength, 'both clients converged to same byteLength')
|
|
98
|
-
assert.equal(s1.byteLength, serverStream.byteLength, 'clients match server')
|
|
99
|
-
|
|
100
|
-
ws1.close(); ws2.close()
|
|
101
|
-
for (const c of wss.clients) c.terminate()
|
|
102
|
-
wss.close()
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test('relay forwards data between server and client without writing its own commits', async ({ assert }) => {
|
|
106
|
-
// Server
|
|
107
|
-
const serverRegistry = new StreamRegistry()
|
|
108
|
-
const serverStream = await serverRegistry.open(KEY)
|
|
109
|
-
serverStream.set({ hello: 'from-server' })
|
|
110
|
-
const serverWss = outletSync(serverRegistry, 0)
|
|
111
|
-
await new Promise(resolve => serverWss.on('listening', resolve))
|
|
112
|
-
const serverPort = serverWss.address().port
|
|
113
|
-
|
|
114
|
-
// Relay: originSync upstream to server, outletSync downstream for clients.
|
|
115
|
-
// The relay never calls set() or commit() — it only accumulates and re-serves
|
|
116
|
-
// the byte stream it receives.
|
|
117
|
-
const relayRegistry = new StreamRegistry()
|
|
118
|
-
const relayStream = await relayRegistry.open(KEY)
|
|
119
|
-
await originSync(relayStream, KEY, 'localhost', serverPort)
|
|
120
|
-
const relayWss = outletSync(relayRegistry, 0)
|
|
121
|
-
await new Promise(resolve => relayWss.on('listening', resolve))
|
|
122
|
-
const relayPort = relayWss.address().port
|
|
123
|
-
|
|
124
|
-
// Client connects to relay only — no direct server connection
|
|
125
|
-
const clientRegistry = new StreamRegistry()
|
|
126
|
-
const clientStream = await clientRegistry.open(KEY)
|
|
127
|
-
const clientWs = await originSync(clientStream, KEY, 'localhost', relayPort)
|
|
128
|
-
|
|
129
|
-
// Server data reaches client via relay
|
|
130
|
-
await waitFor(clientStream, s => s.get('hello') === 'from-server')
|
|
131
|
-
assert.equal(clientStream.get('hello'), 'from-server', 'relay forwarded server data to client')
|
|
132
|
-
|
|
133
|
-
// Client data propagates back through relay to server
|
|
134
|
-
clientStream.set({ hello: 'from-client' })
|
|
135
|
-
await waitFor(serverStream, s => s.get('hello') === 'from-client')
|
|
136
|
-
assert.equal(serverStream.get('hello'), 'from-client', 'relay forwarded client data to server')
|
|
137
|
-
|
|
138
|
-
clientWs.close()
|
|
139
|
-
for (const c of relayWss.clients) c.terminate()
|
|
140
|
-
relayWss.close()
|
|
141
|
-
for (const c of serverWss.clients) c.terminate()
|
|
142
|
-
serverWss.close()
|
|
143
|
-
})
|
|
144
|
-
})
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal DOM mock for testing mount() in Node.
|
|
3
|
-
*
|
|
4
|
-
* Implements only the subset of DOM APIs that hx.js mount() actually calls:
|
|
5
|
-
* - createElement / createElementNS / createTextNode / createComment / createDocumentFragment
|
|
6
|
-
* - appendChild, remove, before, nextSibling, childNodes
|
|
7
|
-
* - setAttribute, getAttribute, removeAttribute, toggleAttribute
|
|
8
|
-
* - textContent (read)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
class MockNode {
|
|
12
|
-
#children = []
|
|
13
|
-
#parent = null
|
|
14
|
-
|
|
15
|
-
constructor (nodeType) {
|
|
16
|
-
this.nodeType = nodeType
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
get childNodes () { return [...this.#children] }
|
|
20
|
-
get parentNode () { return this.#parent }
|
|
21
|
-
|
|
22
|
-
get textContent () {
|
|
23
|
-
return this.#children.map(c => c.textContent).join('')
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
get nextSibling () {
|
|
27
|
-
if (!this.#parent) return null
|
|
28
|
-
const sibs = this.#parent.#children
|
|
29
|
-
return sibs[sibs.indexOf(this) + 1] ?? null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
appendChild (child) {
|
|
33
|
-
if (child.#parent) child.remove()
|
|
34
|
-
child.#parent = this
|
|
35
|
-
this.#children.push(child)
|
|
36
|
-
return child
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
remove () {
|
|
40
|
-
if (!this.#parent) return
|
|
41
|
-
this.#parent.#children = this.#parent.#children.filter(c => c !== this)
|
|
42
|
-
this.#parent = null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
before (...nodes) {
|
|
46
|
-
if (!this.#parent) return
|
|
47
|
-
const i = this.#parent.#children.indexOf(this)
|
|
48
|
-
const toInsert = []
|
|
49
|
-
for (const n of nodes) {
|
|
50
|
-
if (n.nodeType === 11) { // DocumentFragment
|
|
51
|
-
const cs = [...n.#children]
|
|
52
|
-
n.#children = []
|
|
53
|
-
cs.forEach(c => { c.#parent = this.#parent })
|
|
54
|
-
toInsert.push(...cs)
|
|
55
|
-
} else {
|
|
56
|
-
if (n.#parent) n.remove()
|
|
57
|
-
n.#parent = this.#parent
|
|
58
|
-
toInsert.push(n)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
this.#parent.#children.splice(i, 0, ...toInsert)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
class MockElement extends MockNode {
|
|
66
|
-
#attrs = {}
|
|
67
|
-
|
|
68
|
-
constructor (tag) {
|
|
69
|
-
super(1)
|
|
70
|
-
this.tag = tag
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
setAttribute (name, val) { this.#attrs[name] = String(val) }
|
|
74
|
-
getAttribute (name) { return this.#attrs[name] ?? null }
|
|
75
|
-
removeAttribute (name) { delete this.#attrs[name] }
|
|
76
|
-
toggleAttribute (name, force) {
|
|
77
|
-
if (force) this.#attrs[name] = ''
|
|
78
|
-
else delete this.#attrs[name]
|
|
79
|
-
}
|
|
80
|
-
addEventListener () {}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
class MockText extends MockNode {
|
|
84
|
-
constructor (value) {
|
|
85
|
-
super(3)
|
|
86
|
-
this.nodeValue = value
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
get textContent () { return this.nodeValue }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
class MockComment extends MockNode {
|
|
93
|
-
constructor (value) {
|
|
94
|
-
super(8)
|
|
95
|
-
this.nodeValue = value
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
get textContent () { return '' }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
class MockFragment extends MockNode {
|
|
102
|
-
constructor () { super(11) }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export const mockDocument = {
|
|
106
|
-
createElement: tag => new MockElement(tag),
|
|
107
|
-
createElementNS: (_ns, tag) => new MockElement(tag),
|
|
108
|
-
createTextNode: text => new MockText(text),
|
|
109
|
-
createComment: text => new MockComment(text),
|
|
110
|
-
createDocumentFragment: () => new MockFragment()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export { MockNode }
|