@dtudury/streamo 0.1.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/.claude/settings.local.json +10 -0
- package/LICENSE +661 -0
- package/README.md +194 -0
- package/ROADMAP.md +111 -0
- package/bin/streamo.js +238 -0
- package/jsconfig.json +9 -0
- package/package.json +26 -0
- package/public/apps/chat/index.html +61 -0
- package/public/apps/chat/main.js +144 -0
- package/public/apps/styles/proto.css +71 -0
- package/public/index.html +109 -0
- package/public/streamo/Addressifier.js +212 -0
- package/public/streamo/CodecRegistry.js +195 -0
- package/public/streamo/ContentMap.js +79 -0
- package/public/streamo/DESIGN.md +61 -0
- package/public/streamo/Repo.js +176 -0
- package/public/streamo/Repo.test.js +82 -0
- package/public/streamo/RepoRegistry.js +91 -0
- package/public/streamo/RepoRegistry.test.js +87 -0
- package/public/streamo/Signature.js +15 -0
- package/public/streamo/Signer.js +91 -0
- package/public/streamo/Streamo.js +392 -0
- package/public/streamo/Streamo.test.js +205 -0
- package/public/streamo/archiveSync.js +62 -0
- package/public/streamo/chat-cli.js +122 -0
- package/public/streamo/chat-server.js +60 -0
- package/public/streamo/codecs.js +400 -0
- package/public/streamo/fileSync.js +238 -0
- package/public/streamo/h.js +202 -0
- package/public/streamo/h.mount.test.js +67 -0
- package/public/streamo/h.test.js +121 -0
- package/public/streamo/mount.js +248 -0
- package/public/streamo/originSync.js +60 -0
- package/public/streamo/outletSync.js +105 -0
- package/public/streamo/registrySync.js +333 -0
- package/public/streamo/registrySync.test.js +373 -0
- package/public/streamo/s3Sync.js +99 -0
- package/public/streamo/stateFileSync.js +17 -0
- package/public/streamo/sync.test.js +98 -0
- package/public/streamo/utils/NestedSet.js +41 -0
- package/public/streamo/utils/Recaller.js +77 -0
- package/public/streamo/utils/mockDOM.js +113 -0
- package/public/streamo/utils/nextTick.js +22 -0
- package/public/streamo/utils/noble-secp256k1.js +602 -0
- package/public/streamo/utils/testing.js +90 -0
- package/public/streamo/utils.js +57 -0
- package/public/streamo/webSync.js +118 -0
- package/scripts/serve.js +15 -0
- package/smoke.test.js +132 -0
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import { S3Client } from '@aws-sdk/client-s3'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Count objects in S3 with the given prefix (handles pagination).
|
|
6
|
+
*/
|
|
7
|
+
async function listFrameCount (client, bucket, prefix) {
|
|
8
|
+
let count = 0
|
|
9
|
+
let token
|
|
10
|
+
do {
|
|
11
|
+
const res = await client.send(new ListObjectsV2Command({
|
|
12
|
+
Bucket: bucket,
|
|
13
|
+
Prefix: prefix + '/',
|
|
14
|
+
ContinuationToken: token
|
|
15
|
+
}))
|
|
16
|
+
count += res.KeyCount ?? 0
|
|
17
|
+
token = res.NextContinuationToken
|
|
18
|
+
} while (token)
|
|
19
|
+
return count
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function downloadFrame (client, bucket, key) {
|
|
23
|
+
const res = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
|
24
|
+
return new Uint8Array(await res.Body.transformToByteArray())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function uploadFrame (client, bucket, key, data) {
|
|
28
|
+
await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: data }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sync a Stream to/from S3-compatible object storage.
|
|
33
|
+
*
|
|
34
|
+
* Each wire-format frame is stored as a separate numbered object:
|
|
35
|
+
* <publicKeyHex>/000000
|
|
36
|
+
* <publicKeyHex>/000001
|
|
37
|
+
* ...
|
|
38
|
+
*
|
|
39
|
+
* On startup, existing frames are downloaded in order and fed into the stream.
|
|
40
|
+
* New frames are uploaded as they arrive.
|
|
41
|
+
*
|
|
42
|
+
* @param {import('./Stream.js').Stream} stream
|
|
43
|
+
* @param {string} publicKeyHex
|
|
44
|
+
* @param {object} config
|
|
45
|
+
* @param {string} config.bucket
|
|
46
|
+
* @param {string} [config.endpoint]
|
|
47
|
+
* @param {string} [config.region]
|
|
48
|
+
* @param {string} [config.accessKeyId]
|
|
49
|
+
* @param {string} [config.secretAccessKey]
|
|
50
|
+
*/
|
|
51
|
+
export async function s3Sync (stream, publicKeyHex, config) {
|
|
52
|
+
const { bucket, endpoint, region, accessKeyId, secretAccessKey } = config
|
|
53
|
+
|
|
54
|
+
const client = new S3Client({
|
|
55
|
+
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
|
|
56
|
+
...(region ? { region } : {}),
|
|
57
|
+
...(accessKeyId && secretAccessKey
|
|
58
|
+
? { credentials: { accessKeyId, secretAccessKey } }
|
|
59
|
+
: {})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const keyFor = i => `${publicKeyHex}/${String(i).padStart(6, '0')}`
|
|
63
|
+
|
|
64
|
+
// Load existing frames from S3 into the stream
|
|
65
|
+
let frameCount = 0
|
|
66
|
+
try {
|
|
67
|
+
frameCount = await listFrameCount(client, bucket, publicKeyHex)
|
|
68
|
+
if (frameCount > 0) {
|
|
69
|
+
console.log(`[s3] loading ${frameCount} frames for ${publicKeyHex.slice(0, 8)}...`)
|
|
70
|
+
const writer = stream.makeWritableStream().getWriter()
|
|
71
|
+
for (let i = 0; i < frameCount; i++) {
|
|
72
|
+
const frame = await downloadFrame(client, bucket, keyFor(i))
|
|
73
|
+
await writer.write(frame)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error('[s3] load error:', e.message)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Upload new frames as they arrive, skipping already-uploaded ones
|
|
81
|
+
let uploaded = 0
|
|
82
|
+
const reader = stream.makeReadableStream().getReader()
|
|
83
|
+
;(async () => {
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
const { value, done } = await reader.read()
|
|
87
|
+
if (done) break
|
|
88
|
+
if (uploaded < frameCount) {
|
|
89
|
+
uploaded++ // skip frames we just loaded
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
await uploadFrame(client, bucket, keyFor(uploaded), value)
|
|
93
|
+
uploaded++
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error('[s3] upload error:', e.message)
|
|
97
|
+
}
|
|
98
|
+
})()
|
|
99
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Watch a stream and write its current state as JSON to filePath on every change.
|
|
5
|
+
* The file is written whenever a new value is committed (or set on a plain Stream).
|
|
6
|
+
*
|
|
7
|
+
* @param {import('./Stream.js').Stream} stream
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
*/
|
|
10
|
+
export function stateFileSync (stream, filePath) {
|
|
11
|
+
stream.watch('state-file-sync', () => {
|
|
12
|
+
const state = stream.get()
|
|
13
|
+
if (state != null) {
|
|
14
|
+
writeFile(filePath, JSON.stringify(state, null, 2) + '\n').catch(console.error)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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 state', 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 }) // will be a conflict at the streamo level, but both chunks land
|
|
81
|
+
|
|
82
|
+
const ws1 = await originSync(s1, KEY, 'localhost', port)
|
|
83
|
+
const ws2 = await originSync(s2, KEY, 'localhost', port)
|
|
84
|
+
|
|
85
|
+
// Both clients should end up with the same byteLength once chunks propagate
|
|
86
|
+
const serverStream = await serverRegistry.open(KEY)
|
|
87
|
+
await waitFor(serverStream, s => s.byteLength >= s1.byteLength && s.byteLength >= s2.byteLength)
|
|
88
|
+
await waitFor(s1, s => s.byteLength >= serverStream.byteLength)
|
|
89
|
+
await waitFor(s2, s => s.byteLength >= serverStream.byteLength)
|
|
90
|
+
|
|
91
|
+
assert.equal(s1.byteLength, s2.byteLength, 'both clients converged to same byteLength')
|
|
92
|
+
assert.equal(s1.byteLength, serverStream.byteLength, 'clients match server')
|
|
93
|
+
|
|
94
|
+
ws1.close(); ws2.close()
|
|
95
|
+
for (const c of wss.clients) c.terminate()
|
|
96
|
+
wss.close()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class NestedSet {
|
|
2
|
+
#map = new Map()
|
|
3
|
+
#set = new Set()
|
|
4
|
+
|
|
5
|
+
get size () {
|
|
6
|
+
return this.values().length
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
add (root, ...rest) {
|
|
10
|
+
if (rest.length) {
|
|
11
|
+
if (!this.#map.has(root)) this.#map.set(root, new NestedSet())
|
|
12
|
+
return this.#map.get(root).add(...rest)
|
|
13
|
+
}
|
|
14
|
+
return this.#set.add(root)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get (root, ...rest) {
|
|
18
|
+
if (rest.length) return this.#map.get(root)?.get(...rest)
|
|
19
|
+
return this.#map.get(root)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
delete (root, ...rest) {
|
|
23
|
+
if (rest.length) {
|
|
24
|
+
this.#map.get(root)?.delete(...rest)
|
|
25
|
+
} else {
|
|
26
|
+
this.#set.delete(root)
|
|
27
|
+
this.#map.forEach(nested => nested.delete(root))
|
|
28
|
+
}
|
|
29
|
+
if (!this.#map.get(root)?.size) this.#map.delete(root)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
deleteBranch (root, ...rest) {
|
|
33
|
+
if (rest.length) return this.#map.get(root)?.deleteBranch(...rest)
|
|
34
|
+
return this.#map.delete(root)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
values (...path) {
|
|
38
|
+
if (path.length) return this.#map.get(path[0])?.values(...path.slice(1)) ?? []
|
|
39
|
+
return [...new Set([...this.#set, ...[...this.#map.keys()].flatMap(k => this.values(k))])]
|
|
40
|
+
}
|
|
41
|
+
}
|