@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.
@@ -6,7 +6,9 @@
6
6
  "Bash(git push *)",
7
7
  "Bash(gh repo *)",
8
8
  "Bash(git rm *)",
9
- "Bash(node --test)"
9
+ "Bash(node --test)",
10
+ "Bash(node --input-type=module --eval \"import './bin/streamo.js'\" --help)",
11
+ "Bash(node bin/streamo.js --help)"
10
12
  ]
11
13
  }
12
14
  }
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
- streamo --name my-chat --username relay --web 8080 --chat-room
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.1.3)
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. The server is just another streamo node (`--chat-room` flag) — its
55
- public key is the room address, its member list is in its own repo, and it
56
- has no special authority over anyone's data. Runs in the browser and from
57
- the terminal (`chat-cli.js`).
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 now runs through the CLI, which already wires `archiveSync` so
68
- the member list survives restarts automatically. Individual message history lives
69
- in each participant's own repo; persistence there depends on participants running
70
- with `--data-dir` set (the CLI default). The remaining work is ensuring the browser
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 { Signer } from '../public/streamo/Signer.js'
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 ||= question('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 signer = new Signer(options.username, password, options.keyIterations)
124
- const { publicKey } = await signer.keysFor(options.name)
125
- const publicKeyHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '0')).join('')
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 name = options.name
128
- const username = options.username
129
- const envDir = options.envFile ? dirname(options.envFile).replace(/^public\//, '') : null
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', name],
134
- ['USERNAME', 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 = (v) => v + ' '.repeat(maxLength - v.length)
140
- const div = '─'.repeat(maxLength)
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 fileSync(streamo, folder, options.dataDir)
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
- stateFileSync(streamo, options.stateFile)
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 s3Sync(streamo, publicKeyHex, {
173
- bucket: options.s3Bucket,
174
- endpoint: options.s3Endpoint,
175
- region: options.s3Region,
176
- accessKeyId: options.s3AccessKeyId,
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 webSync(registry, publicKeyHex, +options.web, name, options.keyIterations, peerOptions)
172
+ await server.web(+options.web)
200
173
  }
201
174
 
202
175
  if (options.outlet) {
203
176
  const port = +options.outlet
204
- outletSync(registry, port)
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
- const [host, port] = options.origin.split(':')
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 = (...args) => streamo.get(...args)
221
- const set = (...args) => streamo.set(...args)
222
- const ls = () => [...registry].map(([k, s]) => ({ key: k.slice(0, 8) + '…', bytes: s.byteLength }))
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.1.2",
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
+ })
@@ -49,7 +49,10 @@ export class Repo extends Streamo {
49
49
  this.#scheduleSign()
50
50
  }
51
51
  })
52
- .catch(console.error)
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
+ }