@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,205 @@
|
|
|
1
|
+
import { describe } from './utils/testing.js'
|
|
2
|
+
import { Streamo, ConflictError } from './Streamo.js'
|
|
3
|
+
import { Signer } from './Signer.js'
|
|
4
|
+
import { Signature } from './Signature.js'
|
|
5
|
+
|
|
6
|
+
describe(import.meta.url, ({ test }) => {
|
|
7
|
+
test('encodes and decodes primitive values', ({ assert }) => {
|
|
8
|
+
const s = new Streamo()
|
|
9
|
+
const values = [
|
|
10
|
+
undefined, null, false, true,
|
|
11
|
+
0, 1, 127,
|
|
12
|
+
128, -1, 3.14, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY,
|
|
13
|
+
new Uint8Array([1, 2, 3]),
|
|
14
|
+
new Uint8Array([4, 5, 6, 7]),
|
|
15
|
+
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
|
16
|
+
'hello',
|
|
17
|
+
'a longer string that definitely does not fit in four bytes',
|
|
18
|
+
new Date('1969-07-21T22:56:15Z'),
|
|
19
|
+
{ a: 1, b: 2, c: 3 },
|
|
20
|
+
{ x: 'hello' },
|
|
21
|
+
{},
|
|
22
|
+
[1, 2, 3],
|
|
23
|
+
[],
|
|
24
|
+
['a', 'b'],
|
|
25
|
+
new Signature(0, new Uint8Array(64))
|
|
26
|
+
]
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
const code = s.encodeVariable(value)
|
|
29
|
+
const decoded = s.decode(code)
|
|
30
|
+
assert.deepEqual(decoded, value, `round-trips ${Object.prototype.toString.call(value)}`)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('negative addresses for single-byte primitives', ({ assert }) => {
|
|
35
|
+
const s = new Streamo()
|
|
36
|
+
for (const v of [undefined, null, false, true, 0, 1, 127]) {
|
|
37
|
+
const code = s.encode(v)
|
|
38
|
+
assert.equal(code.length, 1, `${String(v)} encodes to 1 byte`)
|
|
39
|
+
const addr = -(code[0] + 1)
|
|
40
|
+
assert.ok(addr < 0, `${String(v)} has a negative address`)
|
|
41
|
+
assert.deepEqual(s.decode(addr), v, `negative address resolves back to ${String(v)}`)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('deduplication: same value always gets the same address', ({ assert }) => {
|
|
46
|
+
const s = new Streamo()
|
|
47
|
+
const a1 = s.append(s.encode(42))
|
|
48
|
+
s.append(s.encode({ x: 42 }))
|
|
49
|
+
const code42 = s.encode(42)
|
|
50
|
+
assert.equal(s.addressOf(code42), a1, 'second encode of 42 reuses the existing address')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('reactive get/set/watch', async ({ assert }) => {
|
|
54
|
+
const s = new Streamo()
|
|
55
|
+
let callCount = 0
|
|
56
|
+
let lastValue
|
|
57
|
+
|
|
58
|
+
s.watch('test', () => {
|
|
59
|
+
lastValue = s.get('greeting')
|
|
60
|
+
callCount++
|
|
61
|
+
})
|
|
62
|
+
assert.equal(callCount, 1, 'watch runs immediately')
|
|
63
|
+
assert.equal(lastValue, undefined, 'no value yet')
|
|
64
|
+
|
|
65
|
+
s.set({ greeting: 'hello' })
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
67
|
+
assert.equal(callCount, 2, 'watch re-ran after set')
|
|
68
|
+
assert.equal(lastValue, 'hello', 'updated value seen')
|
|
69
|
+
|
|
70
|
+
s.set('greeting', 'world')
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
72
|
+
assert.equal(callCount, 3, 'watch re-ran after path set')
|
|
73
|
+
assert.equal(lastValue, 'world', 'path update seen')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('asRefs returns addresses for object values and names', ({ assert }) => {
|
|
77
|
+
const s = new Streamo()
|
|
78
|
+
const code = s.encode({ a: 1 })
|
|
79
|
+
|
|
80
|
+
// asRefs=true: values become addresses, names stay as strings
|
|
81
|
+
const withTrue = s.decode(code, true)
|
|
82
|
+
assert.deepEqual(Object.keys(withTrue), ['a'])
|
|
83
|
+
assert.equal(typeof withTrue.a, 'number', 'value is an address')
|
|
84
|
+
assert.equal(s.decode(withTrue.a), 1, 'address decodes to original value')
|
|
85
|
+
|
|
86
|
+
// asRefs=[true, false]: same — value is address, name is string
|
|
87
|
+
const withValueRef = s.decode(code, [true, false])
|
|
88
|
+
assert.equal(typeof withValueRef.a, 'number')
|
|
89
|
+
assert.equal(s.decode(withValueRef.a), 1)
|
|
90
|
+
|
|
91
|
+
// asRefs=[false, true]: value decoded, name is address
|
|
92
|
+
const withNameRef = s.decode(code, [false, true])
|
|
93
|
+
assert.deepEqual(Object.values(withNameRef), [1])
|
|
94
|
+
const nameAddr = Number(Object.keys(withNameRef)[0])
|
|
95
|
+
assert.equal(s.decode(nameAddr), 'a', 'key address decodes to the name string')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('asRefs: object returns name/address map', ({ assert }) => {
|
|
99
|
+
const s = new Streamo()
|
|
100
|
+
s.set({ x: 1, y: 2 })
|
|
101
|
+
const refs = s.asRefs(s.byteLength - 1)
|
|
102
|
+
assert.deepEqual(Object.keys(refs), ['x', 'y'])
|
|
103
|
+
assert.equal(typeof refs.x, 'number')
|
|
104
|
+
assert.equal(typeof refs.y, 'number')
|
|
105
|
+
assert.equal(s.decode(refs.x), 1)
|
|
106
|
+
assert.equal(s.decode(refs.y), 2)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('asRefs: array returns element addresses', ({ assert }) => {
|
|
110
|
+
const s = new Streamo()
|
|
111
|
+
s.set(['a', 'b', 'c'])
|
|
112
|
+
const refs = s.asRefs(s.byteLength - 1)
|
|
113
|
+
assert.ok(Array.isArray(refs))
|
|
114
|
+
assert.equal(refs.length, 3)
|
|
115
|
+
refs.forEach(addr => assert.equal(typeof addr, 'number'))
|
|
116
|
+
assert.equal(s.decode(refs[0]), 'a')
|
|
117
|
+
assert.equal(s.decode(refs[1]), 'b')
|
|
118
|
+
assert.equal(s.decode(refs[2]), 'c')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('asRefs: non-object returns the address itself', ({ assert }) => {
|
|
122
|
+
const s = new Streamo()
|
|
123
|
+
s.set('hello')
|
|
124
|
+
const address = s.byteLength - 1
|
|
125
|
+
assert.equal(s.asRefs(address), address)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('encode(asRefs(addr), true) round-trips an object', ({ assert }) => {
|
|
129
|
+
const s = new Streamo()
|
|
130
|
+
s.set({ a: 1, b: 'hello' })
|
|
131
|
+
const addr = s.byteLength - 1
|
|
132
|
+
const refs = s.asRefs(addr)
|
|
133
|
+
const code = s.encode(refs, true)
|
|
134
|
+
assert.deepEqual(s.decode(code), { a: 1, b: 'hello' })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('encode(asRefs(addr), true) round-trips an array', ({ assert }) => {
|
|
138
|
+
const s = new Streamo()
|
|
139
|
+
s.set([10, 20, 30])
|
|
140
|
+
const addr = s.byteLength - 1
|
|
141
|
+
const refs = s.asRefs(addr)
|
|
142
|
+
const code = s.encode(refs, true)
|
|
143
|
+
assert.deepEqual(s.decode(code), [10, 20, 30])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('encode(asRefs(addr), true) round-trips a primitive', ({ assert }) => {
|
|
147
|
+
const s = new Streamo()
|
|
148
|
+
s.set('hello')
|
|
149
|
+
const addr = s.byteLength - 1
|
|
150
|
+
const refs = s.asRefs(addr) // returns addr itself for non-objects
|
|
151
|
+
const code = s.encode(refs, true) // resolves addr → string code
|
|
152
|
+
assert.equal(s.decode(code), 'hello')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('sign and verify', async ({ assert }) => {
|
|
156
|
+
const s = new Streamo()
|
|
157
|
+
s.set({ hello: 'world' })
|
|
158
|
+
s.set('hello', 'signed')
|
|
159
|
+
|
|
160
|
+
const signer = new Signer('alice', 'secret')
|
|
161
|
+
const name = 'my-streamo'
|
|
162
|
+
const keys = await signer.keysFor(name)
|
|
163
|
+
const sig = await s.sign(signer, name)
|
|
164
|
+
|
|
165
|
+
assert.ok(sig instanceof Signature)
|
|
166
|
+
assert.ok(await s.verify(sig, keys.publicKey), 'signature verifies with correct key')
|
|
167
|
+
|
|
168
|
+
const other = new Signer('bob', 'different')
|
|
169
|
+
const otherKeys = await other.keysFor(name)
|
|
170
|
+
assert.ok(!(await s.verify(sig, otherKeys.publicKey)), 'wrong key does not verify')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('conditionalSet rejects stale edits and accepts fresh ones', ({ assert }) => {
|
|
174
|
+
const s = new Streamo()
|
|
175
|
+
s.set({ x: 1 })
|
|
176
|
+
const tip = s.byteLength
|
|
177
|
+
|
|
178
|
+
// A concurrent write advances the streamo past tip
|
|
179
|
+
s.set({ x: 2 })
|
|
180
|
+
|
|
181
|
+
// Stale edit is rejected
|
|
182
|
+
let caught
|
|
183
|
+
try { s.conditionalSet(tip, { x: 3 }) } catch (e) { caught = e }
|
|
184
|
+
assert.ok(caught instanceof ConflictError, 'throws ConflictError')
|
|
185
|
+
assert.equal(caught.expectedTip, tip)
|
|
186
|
+
assert.equal(caught.actualTip, s.byteLength)
|
|
187
|
+
assert.equal(s.get('x'), 2, 'streamo unchanged after rejection')
|
|
188
|
+
|
|
189
|
+
// Fresh edit at current tip succeeds
|
|
190
|
+
const freshTip = s.byteLength
|
|
191
|
+
s.conditionalSet(freshTip, { x: 3 })
|
|
192
|
+
assert.equal(s.get('x'), 3, 'fresh conditional set applied')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('clone snapshots state at a given address', ({ assert }) => {
|
|
196
|
+
const s = new Streamo()
|
|
197
|
+
s.set({ v: 1 })
|
|
198
|
+
const addr1 = s.byteLength - 1
|
|
199
|
+
s.set({ v: 2 })
|
|
200
|
+
|
|
201
|
+
const snap = s.clone(addr1)
|
|
202
|
+
assert.equal(snap.get('v'), 1, 'clone reflects state at snapshot address')
|
|
203
|
+
assert.equal(s.get('v'), 2, 'original still reflects latest state')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdir, open, readFile } from 'fs/promises'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load a stream from disk and keep it in sync as new chunks arrive.
|
|
6
|
+
*
|
|
7
|
+
* On startup, reads `<dir>/<publicKeyHex>.bin` (wire format: 4-byte LE length
|
|
8
|
+
* prefix per chunk) and feeds it into the stream via makeWritableStream().
|
|
9
|
+
*
|
|
10
|
+
* Then opens the file for writing and drains makeReadableStream() into it —
|
|
11
|
+
* re-emitting all loaded chunks first, then appending new ones as they arrive.
|
|
12
|
+
* This means the file is always a complete, valid wire-format snapshot.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('./Stream.js').Stream} stream
|
|
15
|
+
* @param {string} dir directory to store archive files in
|
|
16
|
+
* @param {string} publicKeyHex hex-encoded public key, used as filename
|
|
17
|
+
*/
|
|
18
|
+
export async function archiveSync (stream, dir, publicKeyHex) {
|
|
19
|
+
await mkdir(dir, { recursive: true })
|
|
20
|
+
const filePath = join(dir, `${publicKeyHex}.bin`)
|
|
21
|
+
|
|
22
|
+
// Load existing data
|
|
23
|
+
try {
|
|
24
|
+
const bytes = await readFile(filePath)
|
|
25
|
+
if (bytes.length > 0) {
|
|
26
|
+
const writer = stream.makeWritableStream().getWriter()
|
|
27
|
+
await writer.write(bytes)
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// No existing archive — start fresh
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Compact plain Streams: discard accumulated history and keep only the
|
|
34
|
+
// current value. Skipped for Repository subclasses whose commit records
|
|
35
|
+
// embed dataAddress pointers that would become invalid after a reset.
|
|
36
|
+
if (stream.byteLength > 0 && typeof stream.commit !== 'function') {
|
|
37
|
+
try {
|
|
38
|
+
const value = stream.get()
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
stream._reset()
|
|
41
|
+
stream.set(value)
|
|
42
|
+
}
|
|
43
|
+
} catch { /* not compactable */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Rewrite file from chunk 0 and keep appending as new chunks arrive.
|
|
47
|
+
// makeReadableStream() emits all existing chunks then waits indefinitely,
|
|
48
|
+
// so this loop runs for the lifetime of the process.
|
|
49
|
+
const fileHandle = await open(filePath, 'w')
|
|
50
|
+
const reader = stream.makeReadableStream().getReader();
|
|
51
|
+
(async () => {
|
|
52
|
+
try {
|
|
53
|
+
while (true) {
|
|
54
|
+
const { value, done } = await reader.read()
|
|
55
|
+
if (done) break
|
|
56
|
+
await fileHandle.write(value)
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
await fileHandle.close()
|
|
60
|
+
}
|
|
61
|
+
})()
|
|
62
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* streamo chat CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node public/streamo/chat-cli.js [username] [password] [host] [port]
|
|
7
|
+
*
|
|
8
|
+
* Joins the chat room, prints incoming messages, and reads outgoing messages
|
|
9
|
+
* from stdin (one line = one message).
|
|
10
|
+
*
|
|
11
|
+
* Example for Claude to join and help debug:
|
|
12
|
+
* node public/streamo/chat-cli.js claude claude localhost 8080
|
|
13
|
+
*/
|
|
14
|
+
import readline from 'node:readline'
|
|
15
|
+
import { Signer } from './Signer.js'
|
|
16
|
+
import { RepoRegistry } from './RepoRegistry.js'
|
|
17
|
+
import { registrySync } from './registrySync.js'
|
|
18
|
+
import { bytesToHex } from './utils.js'
|
|
19
|
+
|
|
20
|
+
const [,, username = 'claude', password = 'claude', host = 'localhost', portStr = '8080'] = process.argv
|
|
21
|
+
const port = Number(portStr)
|
|
22
|
+
|
|
23
|
+
// Derive identity from username + password (1 iteration = fast for dev)
|
|
24
|
+
const signer = new Signer(username, password, 1)
|
|
25
|
+
const { publicKey } = await signer.keysFor('chat')
|
|
26
|
+
const myKey = bytesToHex(publicKey)
|
|
27
|
+
|
|
28
|
+
// Fetch root key from server
|
|
29
|
+
let rootKey
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`http://${host}:${port}/api/chat-info`)
|
|
32
|
+
;({ rootKey } = await res.json())
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(`could not reach server at http://${host}:${port}: ${e.message}`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`\njoining as ${username}`)
|
|
39
|
+
console.log(`my key : ${myKey.slice(0, 16)}…`)
|
|
40
|
+
console.log(`root key: ${rootKey.slice(0, 16)}…`)
|
|
41
|
+
console.log('─'.repeat(40))
|
|
42
|
+
|
|
43
|
+
const registry = new RepoRegistry()
|
|
44
|
+
const session = await registrySync(registry, host, port, {
|
|
45
|
+
filter: k => k === rootKey,
|
|
46
|
+
follow: (keyHex, repo, subscribe) => {
|
|
47
|
+
// Auto-follow all members listed in the root repo
|
|
48
|
+
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
49
|
+
},
|
|
50
|
+
onAnnounce: (key) => {
|
|
51
|
+
// When someone announces directly, subscribe to their repo immediately
|
|
52
|
+
session.subscribe(key)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Open my own repo, set profile if first time
|
|
57
|
+
const myRepo = await registry.open(myKey)
|
|
58
|
+
if (!myRepo.get('name')) {
|
|
59
|
+
myRepo.set({ name: username, messages: [] })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Announce and express interest
|
|
63
|
+
session.interest(rootKey)
|
|
64
|
+
session.announce(myKey, rootKey)
|
|
65
|
+
|
|
66
|
+
// ── Message rendering ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
// Track last-seen message count per repo so we only print new messages
|
|
69
|
+
const seen = new Map()
|
|
70
|
+
|
|
71
|
+
function printNewMessages (keyHex, repo) {
|
|
72
|
+
const name = repo.get('name')
|
|
73
|
+
if (!name || keyHex === myKey) return // skip unnamed repos and self
|
|
74
|
+
const messages = repo.get('messages') ?? []
|
|
75
|
+
const prev = seen.get(keyHex) ?? 0
|
|
76
|
+
for (let i = prev; i < messages.length; i++) {
|
|
77
|
+
const msg = messages[i]
|
|
78
|
+
const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
|
|
79
|
+
const time = msg?.at ? new Date(msg.at).toLocaleTimeString() : ''
|
|
80
|
+
console.log(`\n${time ? `[${time}] ` : ''}${name}: ${text}`)
|
|
81
|
+
process.stdout.write('> ') // re-print prompt
|
|
82
|
+
}
|
|
83
|
+
seen.set(keyHex, messages.length)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function watchRepo (keyHex, repo) {
|
|
87
|
+
if (seen.has(keyHex)) return
|
|
88
|
+
seen.set(keyHex, 0)
|
|
89
|
+
repo.watch(`chat-cli:${keyHex}`, () => printNewMessages(keyHex, repo))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Watch all repos already in registry
|
|
93
|
+
for (const [k, r] of registry) watchRepo(k, r)
|
|
94
|
+
|
|
95
|
+
// Watch repos that open later
|
|
96
|
+
registry.onOpen((keyHex, repo) => watchRepo(keyHex, repo))
|
|
97
|
+
|
|
98
|
+
// ── Stdin → outgoing messages ──────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const rl = readline.createInterface({
|
|
101
|
+
input: process.stdin,
|
|
102
|
+
output: process.stdout,
|
|
103
|
+
prompt: '> '
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
rl.prompt()
|
|
107
|
+
|
|
108
|
+
rl.on('line', async line => {
|
|
109
|
+
const text = line.trim()
|
|
110
|
+
if (!text) { rl.prompt(); return }
|
|
111
|
+
const messages = myRepo.get('messages') ?? []
|
|
112
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
|
|
113
|
+
rl.prompt()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
rl.on('close', () => {
|
|
117
|
+
console.log('\nbye')
|
|
118
|
+
process.exit(0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
console.log('ready — type a message and press enter\n')
|
|
122
|
+
rl.prompt()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* streamo chat server
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node public/streamo/chat-server.js [port]
|
|
7
|
+
*
|
|
8
|
+
* Starts an HTTP + WebSocket server. Auto-accepts any participant that
|
|
9
|
+
* announces their repo key to the root chat topic.
|
|
10
|
+
*
|
|
11
|
+
* The root key is printed on startup — pass it to clients via the
|
|
12
|
+
* /api/chat-info endpoint or by copying it into the config.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'http'
|
|
15
|
+
import { WebSocketServer } from 'ws'
|
|
16
|
+
import { fileURLToPath } from 'url'
|
|
17
|
+
import { join, dirname } from 'path'
|
|
18
|
+
import express from 'express'
|
|
19
|
+
import { Signer } from './Signer.js'
|
|
20
|
+
import { RepoRegistry } from './RepoRegistry.js'
|
|
21
|
+
import { attachStreamSync } from './outletSync.js'
|
|
22
|
+
import { bytesToHex } from './utils.js'
|
|
23
|
+
|
|
24
|
+
const port = Number(process.argv[2] ?? process.env.PORT ?? 8080)
|
|
25
|
+
const __dir = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
|
|
27
|
+
// Derive a stable, well-known root key for this chat room.
|
|
28
|
+
// Using 1 PBKDF2 iteration so startup is instant; this is fine for a demo.
|
|
29
|
+
const rootSigner = new Signer('streamo-chat-room', 'streamo-chat', 1)
|
|
30
|
+
const { publicKey: rootPubKey } = await rootSigner.keysFor('v1')
|
|
31
|
+
const ROOT_KEY = bytesToHex(rootPubKey)
|
|
32
|
+
|
|
33
|
+
const registry = new RepoRegistry()
|
|
34
|
+
const rootRepo = await registry.open(ROOT_KEY)
|
|
35
|
+
if (!rootRepo.get('members')) rootRepo.set({ name: 'chat-root', members: [] })
|
|
36
|
+
|
|
37
|
+
// Auto-accept: when a client announces their key to the root topic, add them.
|
|
38
|
+
function onAnnounce (key, topic) {
|
|
39
|
+
if (topic !== ROOT_KEY) return
|
|
40
|
+
const members = rootRepo.get('members') ?? []
|
|
41
|
+
if (!members.includes(key)) {
|
|
42
|
+
rootRepo.set({ name: 'chat-root', members: [...members, key] })
|
|
43
|
+
console.log(`[chat] new member: ${key.slice(0, 12)}…`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const app = express()
|
|
48
|
+
app.use(express.static(join(__dir, '../../apps/chat')))
|
|
49
|
+
// Also serve public/streamo/ so the browser can import streamo modules
|
|
50
|
+
app.use('/streamo', express.static(__dir))
|
|
51
|
+
app.get('/api/chat-info', (_req, res) => res.json({ rootKey: ROOT_KEY }))
|
|
52
|
+
|
|
53
|
+
const server = createServer(app)
|
|
54
|
+
const wss = new WebSocketServer({ server })
|
|
55
|
+
attachStreamSync(wss, registry, 'chat', { onAnnounce })
|
|
56
|
+
|
|
57
|
+
server.listen(port, () => {
|
|
58
|
+
console.log(`chat server → http://localhost:${port}`)
|
|
59
|
+
console.log(`root key → ${ROOT_KEY}`)
|
|
60
|
+
})
|