@dtudury/streamo 0.1.2 → 0.2.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 +3 -1
- package/README.md +3 -3
- package/ROADMAP.md +14 -10
- package/bin/streamo.js +33 -64
- package/package.json +1 -1
- package/public/apps/chat/server.js +35 -0
- package/public/streamo/Repo.js +4 -1
- package/public/streamo/StreamoServer.js +71 -0
package/README.md
CHANGED
|
@@ -50,7 +50,6 @@ streamo --env-file .env
|
|
|
50
50
|
| `STREAMO_OUTLET` | `--outlet` | accept inbound peer connections |
|
|
51
51
|
| `STREAMO_ORIGIN` | `--origin` | connect to a remote outlet |
|
|
52
52
|
| `STREAMO_S3_BUCKET` | `--s3-bucket` | S3 bucket for replication |
|
|
53
|
-
| `STREAMO_CHAT_ROOM` | `--chat-room` | auto-accept member announcements; this node's key becomes the room address |
|
|
54
53
|
|
|
55
54
|
## javascript api
|
|
56
55
|
|
|
@@ -184,10 +183,11 @@ For hot-reloading, `componentKey(prefix, address)` and `defineComponent(name, fn
|
|
|
184
183
|
|
|
185
184
|
```bash
|
|
186
185
|
# start the server — its public key becomes the room key
|
|
187
|
-
|
|
186
|
+
STREAMO_NAME=my-chat STREAMO_USERNAME=relay STREAMO_PASSWORD=secret \
|
|
187
|
+
node public/apps/chat/server.js
|
|
188
188
|
|
|
189
189
|
# join from the browser
|
|
190
|
-
open http://localhost:8080
|
|
190
|
+
open http://localhost:8080/apps/chat/
|
|
191
191
|
|
|
192
192
|
# join from the terminal
|
|
193
193
|
node public/streamo/chat-cli.js alice secret localhost 8080
|
package/ROADMAP.md
CHANGED
|
@@ -5,7 +5,7 @@ picture of where the project is and where it's headed.
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
## where we are (0.
|
|
8
|
+
## where we are (0.2.0)
|
|
9
9
|
|
|
10
10
|
The foundation is solid and working. Here's what's in:
|
|
11
11
|
|
|
@@ -51,11 +51,15 @@ The foundation is solid and working. Here's what's in:
|
|
|
51
51
|
|
|
52
52
|
**Apps**
|
|
53
53
|
- Chat — full p2p messaging app. Each participant owns their own signed message
|
|
54
|
-
stream.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
stream. `public/apps/chat/server.js` is the standalone server — its public key
|
|
55
|
+
is the room address, its member list is in its own repo, and it has no special
|
|
56
|
+
authority over anyone's data. Runs in the browser and from the terminal
|
|
57
|
+
(`chat-cli.js`).
|
|
58
58
|
- Homepage at `public/index.html`.
|
|
59
|
+
- `StreamoServer` — reusable class that wraps signer, registry, and all sync
|
|
60
|
+
methods behind a clean API. `bin/streamo.js` is now a thin CLI parser on top
|
|
61
|
+
of it; `public/apps/chat/server.js` is a standalone chat server using the
|
|
62
|
+
same class.
|
|
59
63
|
- `npm run serve` — starts a streamo node (with REPL) using `.env.dev`
|
|
60
64
|
credentials. The dev server is a real peer, not a bare static file server.
|
|
61
65
|
|
|
@@ -64,11 +68,11 @@ The foundation is solid and working. Here's what's in:
|
|
|
64
68
|
## what's next
|
|
65
69
|
|
|
66
70
|
### chat persistence ← start here
|
|
67
|
-
The chat server
|
|
68
|
-
the member list survives restarts automatically. Individual
|
|
69
|
-
in each participant's own repo; persistence there depends
|
|
70
|
-
with `--data-dir` set
|
|
71
|
-
chat client also persists across page reloads.
|
|
71
|
+
The chat server (`public/apps/chat/server.js`) uses `StreamoServer` and wires
|
|
72
|
+
`archiveSync` — so the member list survives restarts automatically. Individual
|
|
73
|
+
message history lives in each participant's own repo; persistence there depends
|
|
74
|
+
on participants running with `--data-dir` set. The remaining work is ensuring the
|
|
75
|
+
browser chat client also persists across page reloads.
|
|
72
76
|
|
|
73
77
|
### presence indicators
|
|
74
78
|
Who's currently online? The `interest` / `announce` layer is ephemeral by design,
|
package/bin/streamo.js
CHANGED
|
@@ -6,16 +6,14 @@ import { Option, program } from 'commander'
|
|
|
6
6
|
import { config } from 'dotenv'
|
|
7
7
|
import { question, questionNewPassword } from 'readline-sync'
|
|
8
8
|
import { start as startRepl } from 'repl'
|
|
9
|
-
import {
|
|
9
|
+
import { StreamoServer } from '../public/streamo/StreamoServer.js'
|
|
10
10
|
import { Repo } from '../public/streamo/Repo.js'
|
|
11
11
|
import { RepoRegistry } from '../public/streamo/RepoRegistry.js'
|
|
12
12
|
import { archiveSync } from '../public/streamo/archiveSync.js'
|
|
13
13
|
import { fileSync } from '../public/streamo/fileSync.js'
|
|
14
14
|
import { outletSync } from '../public/streamo/outletSync.js'
|
|
15
15
|
import { originSync } from '../public/streamo/originSync.js'
|
|
16
|
-
import { webSync } from '../public/streamo/webSync.js'
|
|
17
16
|
import { s3Sync } from '../public/streamo/s3Sync.js'
|
|
18
|
-
import { stateFileSync } from '../public/streamo/stateFileSync.js'
|
|
19
17
|
|
|
20
18
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)))
|
|
21
19
|
|
|
@@ -91,10 +89,6 @@ program
|
|
|
91
89
|
new Option('--interactive', 'start a REPL with streamo, signer, and helpers as globals')
|
|
92
90
|
.env('STREAMO_INTERACTIVE')
|
|
93
91
|
)
|
|
94
|
-
.addOption(
|
|
95
|
-
new Option('--chat-room', 'auto-accept member announcements — this node\'s key becomes the room key (requires --web)')
|
|
96
|
-
.env('STREAMO_CHAT_ROOM')
|
|
97
|
-
)
|
|
98
92
|
.addOption(
|
|
99
93
|
new Option('--key-iterations <number>', 'PBKDF2 iterations for key derivation (lower = faster startup, less secure)')
|
|
100
94
|
.env('STREAMO_KEY_ITERATIONS')
|
|
@@ -116,28 +110,32 @@ if (options.envFile) {
|
|
|
116
110
|
Object.assign(options, program.opts())
|
|
117
111
|
}
|
|
118
112
|
|
|
119
|
-
options.name
|
|
113
|
+
options.name ||= question('Name: ')
|
|
120
114
|
options.username ||= question('Username: ')
|
|
121
115
|
const password = options.password || questionNewPassword('Password [ATTENTION!: Backspace won\'t work here]: ', { min: 4, max: 999 })
|
|
122
116
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
const server = await StreamoServer.create({
|
|
118
|
+
name: options.name,
|
|
119
|
+
username: options.username,
|
|
120
|
+
password,
|
|
121
|
+
dataDir: options.dataDir,
|
|
122
|
+
keyIterations: options.keyIterations,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const { name, username, publicKeyHex, signer, streamo, registry } = server
|
|
126
126
|
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const appPath = (envDir && envDir !== '.') ? `/${envDir}/` : '/'
|
|
131
|
-
const webUrl = options.web ? `http://localhost:${+options.web}${appPath}` : null
|
|
127
|
+
const envDir = options.envFile ? dirname(options.envFile).replace(/^public\//, '') : null
|
|
128
|
+
const appPath = (envDir && envDir !== '.') ? `/${envDir}/` : '/'
|
|
129
|
+
const webUrl = options.web ? `http://localhost:${+options.web}${appPath}` : null
|
|
132
130
|
const rows = [
|
|
133
|
-
['NAME',
|
|
134
|
-
['USERNAME',
|
|
131
|
+
['NAME', name],
|
|
132
|
+
['USERNAME', username],
|
|
135
133
|
['PUBLIC KEY', publicKeyHex],
|
|
136
134
|
...(webUrl ? [['URL', webUrl]] : []),
|
|
137
135
|
]
|
|
138
136
|
const maxLength = Math.max(...rows.map(([, v]) => v.length))
|
|
139
|
-
const pad
|
|
140
|
-
const div
|
|
137
|
+
const pad = (v) => v + ' '.repeat(maxLength - v.length)
|
|
138
|
+
const div = '─'.repeat(maxLength)
|
|
141
139
|
const label = (l) => l.padStart(16)
|
|
142
140
|
console.log(`\x1b[35m
|
|
143
141
|
╭${'─'.repeat(maxLength + 23)}╮
|
|
@@ -148,66 +146,40 @@ ${rows.map(([l, v], i) => [
|
|
|
148
146
|
].filter(Boolean).join('\n')).join('\n')}
|
|
149
147
|
╰──────────────────┴──${'━'.repeat(maxLength)}──╯\x1b[0m`)
|
|
150
148
|
|
|
151
|
-
const dataDir = options.dataDir
|
|
152
|
-
const registry = new RepoRegistry(async key => {
|
|
153
|
-
const repo = new Repo()
|
|
154
|
-
await archiveSync(repo, dataDir, key)
|
|
155
|
-
return repo
|
|
156
|
-
})
|
|
157
|
-
const streamo = await registry.open(publicKeyHex)
|
|
158
|
-
streamo.attachSigner(signer, name)
|
|
159
|
-
|
|
160
149
|
if (options.files) {
|
|
161
150
|
const folder = typeof options.files === 'string' ? options.files : '.'
|
|
162
|
-
await
|
|
151
|
+
await server.files(folder)
|
|
163
152
|
console.log(`\x1b[32mmirroring files: ${folder}\x1b[0m`)
|
|
164
153
|
}
|
|
165
154
|
|
|
166
155
|
if (options.stateFile) {
|
|
167
|
-
|
|
156
|
+
server.stateFile(options.stateFile)
|
|
168
157
|
console.log(`\x1b[32mstate file: ${options.stateFile}\x1b[0m`)
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
if (options.s3Bucket) {
|
|
172
|
-
await
|
|
173
|
-
bucket:
|
|
174
|
-
endpoint:
|
|
175
|
-
region:
|
|
176
|
-
accessKeyId:
|
|
177
|
-
secretAccessKey: options.s3SecretAccessKey
|
|
161
|
+
await server.s3({
|
|
162
|
+
bucket: options.s3Bucket,
|
|
163
|
+
endpoint: options.s3Endpoint,
|
|
164
|
+
region: options.s3Region,
|
|
165
|
+
accessKeyId: options.s3AccessKeyId,
|
|
166
|
+
secretAccessKey: options.s3SecretAccessKey,
|
|
178
167
|
})
|
|
179
168
|
console.log(`\x1b[32ms3: syncing to bucket ${options.s3Bucket}\x1b[0m`)
|
|
180
169
|
}
|
|
181
170
|
|
|
182
|
-
const peerOptions = {}
|
|
183
|
-
if (options.chatRoom) {
|
|
184
|
-
if (!streamo.get('members')) {
|
|
185
|
-
streamo.set({ ...(streamo.get() ?? {}), members: [] })
|
|
186
|
-
console.log('\x1b[32m[chat] initialized chat room\x1b[0m')
|
|
187
|
-
}
|
|
188
|
-
peerOptions.onAnnounce = (key, topic) => {
|
|
189
|
-
if (topic !== publicKeyHex) return
|
|
190
|
-
const members = streamo.get('members') ?? []
|
|
191
|
-
if (!members.includes(key)) {
|
|
192
|
-
streamo.set({ ...(streamo.get() ?? {}), members: [...members, key] })
|
|
193
|
-
console.log(`\x1b[32m[chat] new member: ${key.slice(0, 12)}…\x1b[0m`)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
171
|
if (options.web) {
|
|
199
|
-
await
|
|
172
|
+
await server.web(+options.web)
|
|
200
173
|
}
|
|
201
174
|
|
|
202
175
|
if (options.outlet) {
|
|
203
176
|
const port = +options.outlet
|
|
204
|
-
|
|
177
|
+
server.outlet(port)
|
|
205
178
|
console.log(`\x1b[32moutlet: listening on port ${port}\x1b[0m`)
|
|
206
179
|
}
|
|
207
180
|
|
|
208
181
|
if (options.origin) {
|
|
209
|
-
|
|
210
|
-
await originSync(streamo, publicKeyHex, host, +port)
|
|
182
|
+
await server.connect(options.origin)
|
|
211
183
|
console.log(`\x1b[32morigin: connected to ${options.origin}\x1b[0m`)
|
|
212
184
|
}
|
|
213
185
|
|
|
@@ -217,13 +189,10 @@ if (options.verbose) {
|
|
|
217
189
|
}
|
|
218
190
|
|
|
219
191
|
if (options.interactive) {
|
|
220
|
-
const get
|
|
221
|
-
const set
|
|
222
|
-
const ls
|
|
223
|
-
const connect = (hostPort) =>
|
|
224
|
-
const [host, port] = hostPort.split(':')
|
|
225
|
-
return originSync(streamo, publicKeyHex, host, +port)
|
|
226
|
-
}
|
|
192
|
+
const get = (...args) => streamo.get(...args)
|
|
193
|
+
const set = (...args) => streamo.set(...args)
|
|
194
|
+
const ls = () => [...registry].map(([k, s]) => ({ key: k.slice(0, 8) + '…', bytes: s.byteLength }))
|
|
195
|
+
const connect = (hostPort) => server.connect(hostPort)
|
|
227
196
|
|
|
228
197
|
Object.assign(globalThis, {
|
|
229
198
|
// identity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtudury/streamo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "peer-to-peer sync where your data and identity belong to you, not the server",
|
|
5
5
|
"keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
|
|
6
6
|
"repository": "git@github.com:dtudury/streamo.git",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { config } from 'dotenv'
|
|
4
|
+
import { StreamoServer } from '../../streamo/StreamoServer.js'
|
|
5
|
+
|
|
6
|
+
const envFile = process.argv.find((_, i) => process.argv[i - 1] === '--env-file')
|
|
7
|
+
if (envFile) config({ path: envFile })
|
|
8
|
+
|
|
9
|
+
const name = process.env.STREAMO_NAME ?? 'chat'
|
|
10
|
+
const username = process.env.STREAMO_USERNAME ?? 'relay'
|
|
11
|
+
const password = process.env.STREAMO_PASSWORD ?? ''
|
|
12
|
+
const port = +(process.env.STREAMO_WEB ?? 8080)
|
|
13
|
+
const dataDir = process.env.STREAMO_DATA_DIR ?? '.streamo'
|
|
14
|
+
const keyIter = +(process.env.STREAMO_KEY_ITERATIONS ?? 100000)
|
|
15
|
+
|
|
16
|
+
const server = await StreamoServer.create({ name, username, password, dataDir, keyIterations: keyIter })
|
|
17
|
+
|
|
18
|
+
console.log(`[chat] room key: ${server.publicKeyHex}`)
|
|
19
|
+
console.log(`[chat] serving on http://localhost:${port}/apps/chat/`)
|
|
20
|
+
|
|
21
|
+
if (!server.streamo.get('members')) {
|
|
22
|
+
server.streamo.set({ ...(server.streamo.get() ?? {}), members: [] })
|
|
23
|
+
console.log('[chat] initialized chat room')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await server.web(port, {
|
|
27
|
+
onAnnounce: (key, topic) => {
|
|
28
|
+
if (topic !== server.publicKeyHex) return
|
|
29
|
+
const members = server.streamo.get('members') ?? []
|
|
30
|
+
if (!members.includes(key)) {
|
|
31
|
+
server.streamo.set({ ...(server.streamo.get() ?? {}), members: [...members, key] })
|
|
32
|
+
console.log(`[chat] new member: ${key.slice(0, 12)}…`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
package/public/streamo/Repo.js
CHANGED
|
@@ -49,7 +49,10 @@ export class Repo extends Streamo {
|
|
|
49
49
|
this.#scheduleSign()
|
|
50
50
|
}
|
|
51
51
|
})
|
|
52
|
-
.catch(
|
|
52
|
+
.catch(() => {
|
|
53
|
+
this.#signing = false
|
|
54
|
+
if (this.byteLength > this.signedLength) this.#scheduleSign()
|
|
55
|
+
})
|
|
53
56
|
}
|
|
54
57
|
/**
|
|
55
58
|
* The latest commit record, or null if nothing has been committed yet.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Repo } from './Repo.js'
|
|
2
|
+
import { RepoRegistry } from './RepoRegistry.js'
|
|
3
|
+
import { Signer } from './Signer.js'
|
|
4
|
+
import { archiveSync } from './archiveSync.js'
|
|
5
|
+
import { fileSync } from './fileSync.js'
|
|
6
|
+
import { originSync } from './originSync.js'
|
|
7
|
+
import { outletSync } from './outletSync.js'
|
|
8
|
+
import { s3Sync } from './s3Sync.js'
|
|
9
|
+
import { stateFileSync } from './stateFileSync.js'
|
|
10
|
+
import { bytesToHex } from './utils.js'
|
|
11
|
+
import { webSync } from './webSync.js'
|
|
12
|
+
|
|
13
|
+
export class StreamoServer {
|
|
14
|
+
#dataDir
|
|
15
|
+
#keyIterations
|
|
16
|
+
|
|
17
|
+
name
|
|
18
|
+
username
|
|
19
|
+
publicKeyHex
|
|
20
|
+
signer
|
|
21
|
+
streamo
|
|
22
|
+
registry
|
|
23
|
+
|
|
24
|
+
constructor (fields) {
|
|
25
|
+
Object.assign(this, fields)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static async create ({ name, username, password, dataDir = '.streamo', keyIterations = 100000 }) {
|
|
29
|
+
const signer = new Signer(username, password, keyIterations)
|
|
30
|
+
const { publicKey } = await signer.keysFor(name)
|
|
31
|
+
const publicKeyHex = bytesToHex(publicKey)
|
|
32
|
+
|
|
33
|
+
const registry = new RepoRegistry(async key => {
|
|
34
|
+
const repo = new Repo()
|
|
35
|
+
await archiveSync(repo, dataDir, key)
|
|
36
|
+
return repo
|
|
37
|
+
})
|
|
38
|
+
const streamo = await registry.open(publicKeyHex)
|
|
39
|
+
streamo.attachSigner(signer, name)
|
|
40
|
+
|
|
41
|
+
const server = new StreamoServer({ name, username, publicKeyHex, signer, streamo, registry })
|
|
42
|
+
server.#dataDir = dataDir
|
|
43
|
+
server.#keyIterations = keyIterations
|
|
44
|
+
return server
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async web (port, peerOptions = {}) {
|
|
48
|
+
return webSync(this.registry, this.publicKeyHex, port, this.name, this.#keyIterations, peerOptions)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
outlet (port) {
|
|
52
|
+
return outletSync(this.registry, port)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async connect (hostPort) {
|
|
56
|
+
const [host, port] = hostPort.split(':')
|
|
57
|
+
return originSync(this.streamo, this.publicKeyHex, host, +port)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async files (folder = '.') {
|
|
61
|
+
return fileSync(this.streamo, folder, this.#dataDir)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async s3 ({ bucket, endpoint, region, accessKeyId, secretAccessKey }) {
|
|
65
|
+
return s3Sync(this.streamo, this.publicKeyHex, { bucket, endpoint, region, accessKeyId, secretAccessKey })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stateFile (path) {
|
|
69
|
+
return stateFileSync(this.streamo, path)
|
|
70
|
+
}
|
|
71
|
+
}
|