@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,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
|
+
}
|