@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.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. package/smoke.test.js +132 -0
@@ -0,0 +1,105 @@
1
+ import { WebSocketServer } from 'ws'
2
+ import { hexToBytes } from './utils.js'
3
+ import { handleRegistryPeer } from './registrySync.js'
4
+
5
+ /**
6
+ * Attach the streamo sync protocol to an existing WebSocketServer.
7
+ *
8
+ * Protocol:
9
+ * 1. Client sends a text message containing the hex-encoded public key of
10
+ * the streamo it wants to sync.
11
+ * 2. Server opens (or creates) that streamo and begins full-duplex sync:
12
+ * server → client: all existing chunks, then new ones as they arrive
13
+ * client → server: chunks verified against the streamo's public key
14
+ *
15
+ * Duplicate chunks are silently skipped on both sides (content-addressed
16
+ * dedup). Invalid signature chunks close the connection.
17
+ *
18
+ * @param {WebSocketServer} wss
19
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
20
+ * @param {string} [label] prefix for log messages
21
+ */
22
+ export function attachStreamSync (wss, registry, label = 'ws', peerOptions = {}) {
23
+ // Shared routing state for the ephemeral interest/announce messaging layer.
24
+ // Lives for the lifetime of the server; entries are cleaned up on disconnect.
25
+ const routing = { interestMap: new Map() }
26
+
27
+ wss.on('connection', ws => {
28
+ let reader = null
29
+
30
+ ws.once('message', async rawHandshake => {
31
+ const handshake = rawHandshake.toString().trim()
32
+
33
+ if (handshake === 'registry') {
34
+ handleRegistryPeer(ws, registry, peerOptions, label, routing)
35
+ return
36
+ }
37
+
38
+ const publicKeyHex = handshake
39
+
40
+ // Buffer any data frames that arrive while we're opening the streamo,
41
+ // so nothing is dropped during the async gap after the handshake.
42
+ const pending = []
43
+ const buffer = data => pending.push(data)
44
+ ws.on('message', buffer)
45
+
46
+ let streamo
47
+ try {
48
+ streamo = await registry.open(publicKeyHex)
49
+ } catch (e) {
50
+ console.error(`[${label}] failed to open streamo ${publicKeyHex.slice(0, 8)}...: ${e.message}`)
51
+ ws.close()
52
+ return
53
+ }
54
+
55
+ ws.off('message', buffer)
56
+
57
+ // Streamo → peer: replay all chunks, then stream new ones
58
+ reader = streamo.makeReadableStream().getReader()
59
+ ;(async () => {
60
+ try {
61
+ while (true) {
62
+ const { value, done } = await reader.read()
63
+ if (done) break
64
+ if (ws.readyState === ws.OPEN) ws.send(value)
65
+ else break
66
+ }
67
+ } catch {}
68
+ })()
69
+
70
+ // Peer → streamo: verify signature chunks before accepting
71
+ const publicKey = hexToBytes(publicKeyHex)
72
+ const writer = streamo.makeVerifiedWritableStream(publicKey).getWriter()
73
+
74
+ const writeChunk = data => {
75
+ writer.write(new Uint8Array(data)).catch(e => {
76
+ console.error(`[${label}] rejected chunk from ${publicKeyHex.slice(0, 8)}...: ${e.message}`)
77
+ ws.close()
78
+ })
79
+ }
80
+
81
+ // Drain buffered frames, then handle live ones
82
+ for (const data of pending) writeChunk(data)
83
+ ws.on('message', writeChunk)
84
+ })
85
+
86
+ ws.on('close', () => reader?.cancel().catch(() => {}))
87
+ ws.on('error', err => {
88
+ console.error(`[${label}] connection error:`, err.message)
89
+ reader?.cancel().catch(() => {})
90
+ })
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Start a standalone WebSocket server that syncs streamos from a RepoRegistry.
96
+ *
97
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
98
+ * @param {number} port
99
+ * @returns {WebSocketServer}
100
+ */
101
+ export function outletSync (registry, port) {
102
+ const wss = new WebSocketServer({ port })
103
+ attachStreamSync(wss, registry, 'outlet')
104
+ return wss
105
+ }
@@ -0,0 +1,333 @@
1
+ // Use native WebSocket in the browser; fall back to the `ws` package in Node.
2
+ const WS = globalThis.WebSocket ?? (await import('ws')).default
3
+
4
+ import { hexToBytes, bytesToHex } from './utils.js'
5
+
6
+ /**
7
+ * Normalize a browser-native WebSocket to the Node `ws` EventEmitter interface.
8
+ * If the socket already has `.on()` (Node ws package) it is returned unchanged.
9
+ * @param {WebSocket} ws
10
+ * @returns {WebSocket}
11
+ */
12
+ function adaptWebSocket (ws) {
13
+ if (typeof ws.on === 'function') return ws
14
+ ws.binaryType = 'arraybuffer'
15
+ const h = { open: [], close: [], error: [], message: [] }
16
+ ws.addEventListener('open', () => h.open.forEach(fn => fn()))
17
+ ws.addEventListener('close', e => h.close.forEach(fn => fn(e.code, e.reason)))
18
+ ws.addEventListener('error', e => h.error.forEach(fn => fn(e)))
19
+ ws.addEventListener('message', e => {
20
+ const data = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : e.data
21
+ h.message.forEach(fn => fn(data))
22
+ })
23
+ ws.on = (ev, fn) => { h[ev]?.push(fn); return ws }
24
+ ws.off = (ev, fn) => { if (h[ev]) h[ev] = h[ev].filter(f => f !== fn); return ws }
25
+ return ws
26
+ }
27
+
28
+ // Compressed secp256k1 public keys are always 33 bytes (0x02 or 0x03 prefix).
29
+ // Binary frames are prefixed with the raw key bytes so each chunk can be routed
30
+ // to the correct repository without any per-connection state table.
31
+ const KEY_BYTES = 33
32
+
33
+ /**
34
+ * @typedef {Object} RegistrySyncOptions
35
+ *
36
+ * @property {(keyHex: string) => boolean} [filter]
37
+ * Called for each key announced in the peer's catalog. Return true to
38
+ * subscribe (and start syncing) that repository. Defaults to subscribing
39
+ * to everything. Keys discovered via `follow` are always subscribed regardless
40
+ * of this filter — the assumption is that if your own data references a repo
41
+ * you want it.
42
+ *
43
+ * @property {(keyHex: string, repo: import('./Repo.js').Repo, subscribe: (keyHex: string) => void) => void} [follow]
44
+ * Called reactively whenever a synced repository's value changes. Use this
45
+ * to extract repository keys embedded in the data and call `subscribe(key)`
46
+ * on each one. The registry will then sync that repo too, and `follow` will
47
+ * be called on it in turn — so discovery propagates through the graph.
48
+ *
49
+ * Example — chat app where the chat repo lists participant keys:
50
+ *
51
+ * follow: (keyHex, repo, subscribe) => {
52
+ * for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
53
+ * }
54
+ *
55
+ * `subscribe` is idempotent and safe to call for already-synced repos.
56
+ *
57
+ * @property {(key: string, topic: string) => void} [onAnnounce]
58
+ * Called when a remote peer announces a repository as related to a topic.
59
+ * `key` is the announced repository's hex public key; `topic` is the hex key
60
+ * of the repository it was announced under. Only fires for topics you have
61
+ * previously declared interest in via `session.interest(topicKey)`.
62
+ */
63
+
64
+ /**
65
+ * Attach bidirectional multi-repository sync to an already-open WebSocket.
66
+ *
67
+ * ## Protocol (after the "registry" text handshake)
68
+ *
69
+ * ### Control messages — JSON text frames
70
+ *
71
+ * { "type": "catalog", "keys": ["hex1", "hex2", ...] }
72
+ * Announce the full set of repositories this side currently has open.
73
+ * Sent once on connect and again whenever a new repo is opened.
74
+ *
75
+ * { "type": "subscribe", "key": "hex1" }
76
+ * Request to sync a repository bidirectionally. The sender will stream
77
+ * its copy of the repo to the peer AND expects the peer to stream back.
78
+ * Both sides set up a makeVerifiedWritableStream for the key so only
79
+ * correctly-signed chunks are accepted.
80
+ *
81
+ * ### Data frames — binary
82
+ *
83
+ * [33 bytes: compressed secp256k1 public key][N bytes: stream chunk]
84
+ *
85
+ * The 33-byte prefix identifies which repository the chunk belongs to
86
+ * (secp256k1 keys always start with 0x02 or 0x03; JSON control messages
87
+ * always start with 0x7B '{', so the two are unambiguous).
88
+ * The chunk bytes are taken directly from makeReadableStream() and fed
89
+ * directly into makeVerifiedWritableStream() on the other side.
90
+ *
91
+ * ## Discovery via `follow`
92
+ *
93
+ * When a `follow` function is provided, it is called via recaller.watch()
94
+ * whenever a synced repository's value changes. Calling `subscribe(key)` inside
95
+ * `follow` causes that key to be synced too, and `follow` will be called on it
96
+ * in turn. This lets a graph of related repositories be discovered organically
97
+ * from content — no out-of-band catalog is needed.
98
+ *
99
+ * @param {WebSocket} ws
100
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
101
+ * @param {RegistrySyncOptions} [options]
102
+ * @param {string} [label] prefix for log messages
103
+ */
104
+ export function handleRegistryPeer (ws, registry, options = {}, label = 'registry', routing = null) {
105
+ const { filter = () => true, follow = null, onAnnounce = null } = options
106
+
107
+ const readers = new Map() // keyHex → ReadableStreamDefaultReader (we → peer)
108
+ const writers = new Map() // keyHex → WritableStreamDefaultWriter (peer → us)
109
+ const pendingChunks = new Map() // keyHex → Uint8Array[] (buffered while writer opens)
110
+ const followFns = new Map() // keyHex → fn registered with recaller.watch
111
+
112
+ function sendJson (msg) {
113
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg))
114
+ }
115
+
116
+ function sendCatalog () {
117
+ const keys = [...registry].map(([k]) => k)
118
+ sendJson({ type: 'catalog', keys })
119
+ }
120
+
121
+ function handleWriteError (keyHex, e) {
122
+ console.error(`[${label}] rejected chunk for ${keyHex.slice(0, 8)}...: ${e.message}`)
123
+ ws.close()
124
+ }
125
+
126
+ /**
127
+ * Ensure full bidirectional sync is active for keyHex.
128
+ * Idempotent — safe to call multiple times for the same key.
129
+ */
130
+ async function syncKey (keyHex) {
131
+ const repo = await registry.open(keyHex)
132
+
133
+ // We → peer: replay all existing chunks then stream new ones
134
+ if (!readers.has(keyHex)) {
135
+ const keyBytes = hexToBytes(keyHex)
136
+ const reader = repo.makeReadableStream().getReader()
137
+ readers.set(keyHex, reader)
138
+ ;(async () => {
139
+ try {
140
+ while (true) {
141
+ const { value, done } = await reader.read()
142
+ if (done) break
143
+ if (ws.readyState === ws.OPEN) {
144
+ const frame = new Uint8Array(KEY_BYTES + value.length)
145
+ frame.set(keyBytes, 0)
146
+ frame.set(value, KEY_BYTES)
147
+ ws.send(frame)
148
+ } else break
149
+ }
150
+ } catch {}
151
+ })()
152
+ }
153
+
154
+ // Peer → us: accept verified chunks; drain anything buffered during setup
155
+ if (!writers.has(keyHex)) {
156
+ const publicKey = hexToBytes(keyHex)
157
+ const writer = repo.makeVerifiedWritableStream(publicKey).getWriter()
158
+ writers.set(keyHex, writer)
159
+ const pending = pendingChunks.get(keyHex) ?? []
160
+ pendingChunks.delete(keyHex)
161
+ for (const chunk of pending) {
162
+ writer.write(chunk).catch(e => handleWriteError(keyHex, e))
163
+ }
164
+ }
165
+
166
+ // Content-driven discovery: watch this repo's value and subscribe to any
167
+ // keys the `follow` callback extracts from it. Runs immediately (to catch
168
+ // existing data) and re-runs whenever the repo's value changes.
169
+ if (follow && !followFns.has(keyHex)) {
170
+ const fn = () => follow(keyHex, repo, key => subscribeToKey(key))
171
+ followFns.set(keyHex, fn)
172
+ repo.recaller.watch(`registry-follow:${keyHex}`, fn)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Subscribe to keyHex from the peer: set up local sync then announce intent.
178
+ * Sending "subscribe" before streaming ensures the peer has its writer ready
179
+ * before our chunks arrive.
180
+ */
181
+ async function subscribeToKey (keyHex) {
182
+ if (writers.has(keyHex)) return // already subscribed
183
+ sendJson({ type: 'subscribe', key: keyHex })
184
+ await syncKey(keyHex)
185
+ }
186
+
187
+ // When our registry gains a new repo, update the peer's catalog view
188
+ const onNewRepo = () => sendCatalog()
189
+ registry.onOpen(onNewRepo)
190
+
191
+ // Announce what we already have
192
+ sendCatalog()
193
+
194
+ ws.on('message', async data => {
195
+ // Normalize to Uint8Array — works for Node Buffer, ArrayBuffer, Uint8Array, string
196
+ const buf = typeof data === 'string' ? new TextEncoder().encode(data)
197
+ : data instanceof Uint8Array ? data
198
+ : new Uint8Array(data)
199
+ if (!buf.length) return
200
+
201
+ if (buf[0] === 0x7B) {
202
+ // JSON control message ('{' = 0x7B; secp256k1 keys start with 0x02 or 0x03)
203
+ try {
204
+ const msg = JSON.parse(new TextDecoder().decode(buf))
205
+ if (msg.type === 'catalog') {
206
+ for (const key of msg.keys) {
207
+ if (filter(key)) await subscribeToKey(key)
208
+ }
209
+ } else if (msg.type === 'subscribe') {
210
+ await syncKey(msg.key)
211
+ } else if (msg.type === 'interest') {
212
+ if (routing) {
213
+ const { interestMap } = routing
214
+ if (!interestMap.has(msg.key)) interestMap.set(msg.key, new Set())
215
+ interestMap.get(msg.key).add(ws)
216
+ }
217
+ } else if (msg.type === 'announce') {
218
+ // Fan out to all subscribers of this topic (server-side routing)
219
+ if (routing) {
220
+ for (const sub of routing.interestMap.get(msg.topic) ?? []) {
221
+ if (sub !== ws && sub.readyState === sub.OPEN)
222
+ sub.send(JSON.stringify({ type: 'announce', key: msg.key, topic: msg.topic }))
223
+ }
224
+ }
225
+ // Deliver to local callback (client-side)
226
+ onAnnounce?.(msg.key, msg.topic)
227
+ }
228
+ } catch (e) {
229
+ console.error(`[${label}] bad control message: ${e.message}`)
230
+ }
231
+ } else {
232
+ // Binary frame: [33-byte key prefix][chunk]
233
+ if (buf.length <= KEY_BYTES) return
234
+ const keyHex = bytesToHex(buf.subarray(0, KEY_BYTES))
235
+ const chunk = new Uint8Array(buf.slice(KEY_BYTES))
236
+ const writer = writers.get(keyHex)
237
+ if (writer) {
238
+ writer.write(chunk).catch(e => handleWriteError(keyHex, e))
239
+ } else {
240
+ // Writer is being set up asynchronously — buffer until ready
241
+ if (!pendingChunks.has(keyHex)) pendingChunks.set(keyHex, [])
242
+ pendingChunks.get(keyHex).push(chunk)
243
+ }
244
+ }
245
+ })
246
+
247
+ function cleanup () {
248
+ registry.offOpen(onNewRepo)
249
+ for (const reader of readers.values()) reader.cancel().catch(() => {})
250
+ for (const [keyHex, fn] of followFns) {
251
+ registry.get(keyHex)?.recaller.unwatch(fn)
252
+ }
253
+ if (routing) {
254
+ for (const subs of routing.interestMap.values()) subs.delete(ws)
255
+ }
256
+ }
257
+
258
+ ws.on('close', cleanup)
259
+ ws.on('error', err => {
260
+ console.error(`[${label}] connection error: ${err.message}`)
261
+ cleanup()
262
+ })
263
+
264
+ return {
265
+ /** Declare interest in a topic — receive future `announce` messages for it. */
266
+ interest (key) { sendJson({ type: 'interest', key }) },
267
+ /** Announce `key` as related to `topic` — routed to all peers interested in that topic. */
268
+ announce (key, topic) { sendJson({ type: 'announce', key, topic }) },
269
+ /** Subscribe to a specific repo key, bypassing the catalog filter. */
270
+ subscribe (key) { return subscribeToKey(key) }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @typedef {Object} RegistrySession
276
+ * @property {WebSocket} ws The underlying WebSocket connection.
277
+ * @property {() => void} close Close the connection.
278
+ * @property {(key: string) => void} interest
279
+ * Declare interest in a topic. The server will route `announce` messages
280
+ * for this topic to you via the `onAnnounce` callback in options.
281
+ * @property {(key: string, topic: string) => void} announce
282
+ * Announce a repository as related to `topic`. The server fans this out to
283
+ * all other connected peers that have called `interest(topic)`. Ephemeral —
284
+ * no persistence, only routes to currently-connected interested peers.
285
+ */
286
+
287
+ /**
288
+ * Connect a local RepoRegistry to a remote one and sync repositories.
289
+ *
290
+ * Sends `"registry"` as the WebSocket handshake, then negotiates which
291
+ * repositories to sync via catalog/subscribe messages. Returns a session
292
+ * object with `interest` and `announce` for the ephemeral messaging layer.
293
+ *
294
+ * ### Basic usage — sync everything
295
+ *
296
+ * const { ws } = await registrySync(myRegistry, 'localhost', 8080)
297
+ *
298
+ * ### Ephemeral messaging — express interest and announce related repos
299
+ *
300
+ * const session = await registrySync(myRegistry, 'localhost', 8080, {
301
+ * onAnnounce: (key, topic) => { console.log(key, 'is related to', topic) }
302
+ * })
303
+ * session.interest(rootKey) // start receiving announcements for rootKey
304
+ * session.announce(myKey, rootKey) // tell interested peers about myKey
305
+ *
306
+ * ### Catalog filter and content-driven discovery
307
+ *
308
+ * const session = await registrySync(myRegistry, 'localhost', 8080, {
309
+ * filter: key => key === rootChatKey,
310
+ * follow: (keyHex, repo, subscribe) => {
311
+ * for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
312
+ * }
313
+ * })
314
+ *
315
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
316
+ * @param {string} host
317
+ * @param {number} port
318
+ * @param {RegistrySyncOptions} [options]
319
+ * @returns {Promise<RegistrySession>}
320
+ */
321
+ export function registrySync (registry, host, port, options = {}) {
322
+ return new Promise((resolve, reject) => {
323
+ const ws = adaptWebSocket(new WS(`ws://${host}:${port}`))
324
+
325
+ ws.on('open', () => {
326
+ ws.send('registry')
327
+ const peer = handleRegistryPeer(ws, registry, options, 'origin-registry')
328
+ resolve({ ws, close: () => ws.close(), ...peer })
329
+ })
330
+
331
+ ws.on('error', reject)
332
+ })
333
+ }