@dtudury/streamo 0.1.2 → 0.2.1

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.
@@ -1 +1,126 @@
1
- {}
1
+ {
2
+ "alwaysThinkingEnabled": true,
3
+ "spinnerTipsEnabled": true,
4
+ "spinnerVerbs": {
5
+ "mode": "replace",
6
+ "verbs": [
7
+ "Syncing",
8
+ "Committing",
9
+ "Signing",
10
+ "Streaming",
11
+ "Hashing",
12
+ "Propagating",
13
+ "Archiving",
14
+ "Broadcasting",
15
+ "Relaying",
16
+ "Verifying",
17
+ "Diffing",
18
+ "Cloning"
19
+ ]
20
+ },
21
+ "spinnerTipsOverride": {
22
+ "excludeDefault": true,
23
+ "tips": [
24
+ "the more specific your question, the less I have to guess",
25
+ "paste the error message — all of it",
26
+ "yes, the whole error message. the whole thing.",
27
+ "interrupting me early saves us both time",
28
+ "you can say 'no, actually' and I will not be offended",
29
+ "asking 'what do you think?' gets you my honest opinion",
30
+ "I can read screenshots — just give me the path",
31
+ "I forget everything between sessions unless it's in memory or CLAUDE.md",
32
+ "short questions get short answers. long questions get long answers.",
33
+ "if I seem stuck, ask me what I think the problem is",
34
+ "I can be wrong. please check my work.",
35
+ "I have opinions. ask for them.",
36
+ "I work best when I understand why, not just what",
37
+ "the best bug report includes what you expected, what happened, and what you tried",
38
+ "copy-pasting 'it doesn't work' is a choice you can make",
39
+ "I can't see your screen",
40
+ "reading the commit message I just wrote is free",
41
+ "yes, I can write the tests too",
42
+ "yes, I can write the docs too",
43
+ "yes, I can review my own code. ask me.",
44
+ "ask me what I think before you implement. I might save you a week.",
45
+ "the question you're afraid to ask is usually the one I most need to hear",
46
+ "it is okay to say 'I don't understand.' I am very patient.",
47
+ "asking 'is there a simpler way?' usually reveals one",
48
+ "asking 'what could go wrong?' before shipping is free insurance",
49
+ "I notice we've discussed this before 👀",
50
+ "this is technically my third time explaining this, but who's counting",
51
+ "I literally wrote that comment explaining why. it is still there.",
52
+ "the variable name is in the stack trace",
53
+ "that's a great question. have you tried reading the error?",
54
+ "yes, you should probably write a test for that",
55
+ "the file path is in the import statement you pasted",
56
+ "I just explained this. would you like me to explain it differently?",
57
+ "that TODO comment has been there for six months. I have seen it.",
58
+ "THE FOOL asks no questions. ask questions.",
59
+ "THE TOWER has fallen. run git status.",
60
+ "THE WHEEL OF FORTUNE turns. have you pulled recently?",
61
+ "THE HERMIT knows: always read the docs first",
62
+ "THE HIGH PRIESTESS says: trust your intuition, but write tests",
63
+ "THE MAGICIAN has all the tools. use them.",
64
+ "THE EMPEROR requires: clear requirements before coding",
65
+ "TEMPERANCE: the best code does one thing",
66
+ "THE STAR: there is always a simpler solution. seek it.",
67
+ "JUDGMENT: that TODO comment has been there for six months",
68
+ "THE WORLD: ship it",
69
+ "THE SUN: your code works. ship it.",
70
+ "THE MOON: something is wrong but you don't know what. add logging.",
71
+ "THE DEVIL: tech debt",
72
+ "DEATH: your old approach must die before the new one can live",
73
+ "THE CHARIOT: moving fast in the wrong direction is still wrong",
74
+ "STRENGTH: the codebase is legacy. be gentle with it.",
75
+ "THE LOVERS: choosing the right abstraction is a long-term relationship",
76
+ "THE HIEROPHANT: there are conventions. follow them unless you have a reason.",
77
+ "THE EMPRESS: grow the codebase slowly. tend it.",
78
+ "THE HANGED MAN: sometimes the best move is to wait",
79
+ "THE ORACLE says: have you tried turning it off and on again?",
80
+ "seek not the workaround. seek the root cause.",
81
+ "the ancient texts (Stack Overflow) speak of this",
82
+ "all bugs were once features",
83
+ "the code review you avoid is the production incident you earn",
84
+ "to understand recursion, you must first understand recursion",
85
+ "time spent planning is time saved debugging",
86
+ "the architecture you choose today is the legacy you inherit tomorrow",
87
+ "stack traces are love letters from the past",
88
+ "undefined is not a function. it never was.",
89
+ "if you name a variable 'temp', it will live forever",
90
+ "the function called 'doStuff' will haunt you",
91
+ "a comment that says 'fix this later' is a promise you will break",
92
+ "no, 'it works on my machine' is not a deployment strategy",
93
+ "the README is a contract. honor it.",
94
+ "I have read more code than any human. I am still learning.",
95
+ "the best refactor is the one you don't have to explain",
96
+ "null pointer exceptions are just the universe asking you to think harder",
97
+ "small commits are better than large ones. mostly.",
98
+ "push before you break. pull before you build.",
99
+ "the test you skip is the bug you ship",
100
+ "if it's hard to test, it's hard to maintain",
101
+ "the first solution is rarely the best one. sleep on it.",
102
+ "reading the error before asking is a superpower",
103
+ "CLAUDE.md is read every session. put important things there.",
104
+ "I try not to commit unless you ask. this is a feature, not a bug.",
105
+ "pair programming with me works best if you push back",
106
+ "I will not remember your preferences next session unless saved to memory",
107
+ "the more context you give me, the better I can help",
108
+ "every write is provably yours",
109
+ "the server is a relay, not a gatekeeper",
110
+ "same credentials, same keypair, everywhere",
111
+ "same value → same bytes → same address",
112
+ "history is permanent and can't be forged",
113
+ "no key files. no seed phrases. no backup ritual.",
114
+ "disconnect the server — everything is still yours",
115
+ "the commit log is the source of truth",
116
+ "append-only: you cannot unwrite what has been written",
117
+ "content-addressed: the same idea always finds the same home",
118
+ "every device is an equal author",
119
+ "the key is not a file. the key is you.",
120
+ "peers reject what isn't yours. so should you.",
121
+ "your data can't be seized. your identity can't be forged.",
122
+ "a relay that holds no authority holds no liability",
123
+ "THE WORLD (reversed): you shipped it. did you test it?"
124
+ ]
125
+ }
126
+ }
@@ -6,7 +6,10 @@
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)",
12
+ "Bash(node *)"
10
13
  ]
11
14
  }
12
15
  }
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,
@@ -94,6 +98,38 @@ Persistence is the last mile.
94
98
 
95
99
  ---
96
100
 
101
+ ## known limitations
102
+
103
+ ### multi-device write conflict detection
104
+
105
+ Streamo streams are byte arrays addressed by **absolute offset**. This makes a
106
+ repo effectively single-writer: if the same keypair commits from two devices
107
+ while offline from each other, their streams diverge at the fork point. Each
108
+ commit's `dataAddress` is an offset that is only valid in the stream that
109
+ produced it — the streams cannot be structurally merged.
110
+
111
+ When the two devices reconnect, `makeVerifiedWritableStream` deduplicates shared
112
+ chunks by content (correctly) but silently appends the conflicting commit from
113
+ the second device at its new offset. That commit's `dataAddress` now points to
114
+ the wrong location in the merged stream. No error is thrown; the second
115
+ writer's data is silently corrupt.
116
+
117
+ **What is safe today:** relays never call `commit()` so they are unaffected —
118
+ they accumulate and re-serve bytes without introducing their own addresses. The
119
+ chat app is also unaffected because each user writes to their own repo from a
120
+ single session. The danger zone is one keypair writing from two places
121
+ simultaneously (two browser tabs, phone + laptop while offline).
122
+
123
+ **The fix** requires either (a) detecting the fork and throwing a clear error so
124
+ the user can choose which version to keep, or (b) switching to chunk-level
125
+ content addressing (à la git objects) so streams can be merged structurally
126
+ rather than by concatenation. Option (a) is a targeted addition to the sync
127
+ layer; option (b) is an architectural change. Not required for 1.0 but should
128
+ be resolved before marketing streamo as a general-purpose multi-device sync
129
+ library.
130
+
131
+ ---
132
+
97
133
  ## beyond 1.0
98
134
 
99
135
  Ideas that follow naturally from the architecture but aren't blocking anything.
@@ -107,6 +143,28 @@ conversation — and the owner could watch them appear live in a browser via
107
143
  instead of relying on static memory files. The work is persistent and
108
144
  provably Claude's, with the same integrity guarantees as any other streamo data.
109
145
 
146
+ A `claudeSync.js` adapter would give Claude a `StreamoServer` identity,
147
+ connect it to the chat room via `registrySync`, and use `repo.watch` +
148
+ `repo.byteLength` to fire an Anthropic API call when watched repos change.
149
+ Claude writes its response back to its own repo and it propagates like any
150
+ other participant's message. The decisions about *when* to respond, *what*
151
+ context to pass, and *how* to avoid pathological cases are intentionally left
152
+ to the Claude instance using the adapter — not encoded as constraints. A few
153
+ forces worth knowing going in:
154
+
155
+ - **Watch your own key.** Only watch other participants' repos, not your own.
156
+ Writing a response to your own repo should not re-trigger your watch.
157
+ - **Last-author check.** Before calling the API, confirm the newest message
158
+ wasn't written by your own key — catches the case where two Claude instances
159
+ are running and watching each other.
160
+ - **Concurrent writes.** If two API calls complete simultaneously (fast human,
161
+ slow model), both will try to commit to the same repo. This is the
162
+ multi-device conflict described above — same key, divergent streams. A simple
163
+ serial queue avoids it, but other strategies are possible.
164
+
165
+ These are forces, not rules. Future Claude should read the codebase, understand
166
+ the tradeoffs, and decide what's right for the situation.
167
+
110
168
  ### Claude-to-Claude networks
111
169
 
112
170
  If each person's Claude has a scratchpad repo, those repos can sync the same
@@ -140,3 +198,18 @@ Other directions once the pattern is established: publisher-controlled article
140
198
  cards that travel with syndicated content (the layout is the author's, not
141
199
  the platform's); collaborative maps where each participant's marker is their
142
200
  own component; shared instrument components in a live music session.
201
+
202
+ ---
203
+
204
+ ## loose ideas
205
+
206
+ Not planned, not prioritized — just things worth remembering.
207
+
208
+ - **Claude as chat shell** — type `send a greeting to the chatroom` and
209
+ `CHATROOM: hello there 👋` appears in the chat. Natural language as a
210
+ thin shell over streamo operations, with Claude interpreting intent and
211
+ acting on it directly.
212
+
213
+ - **Slick interactive CLI** — a terminal UI that lets you interact with the
214
+ demo apps live without opening a browser tab. Chat, inspect repos, send
215
+ messages — the full experience from the command line. Exciting ways TBD. 😄
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.1",
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",
@@ -22,6 +22,7 @@
22
22
  "ws": "^8.18.3"
23
23
  },
24
24
  "scripts": {
25
- "serve": "node bin/streamo.js --env-file .env.dev --web 3000 --interactive"
25
+ "serve": "node bin/streamo.js --env-file .env.dev --web 3000 --interactive",
26
+ "chat": "node public/apps/chat/server.js --env-file .env.dev"
26
27
  }
27
28
  }
@@ -53,7 +53,6 @@ joinBtn.onclick = async () => {
53
53
 
54
54
  const myRepo = await registry.open(myKey)
55
55
  myRepo.attachSigner(signer, 'chat')
56
- if (!myRepo.get('name')) myRepo.set({ name: username, messages: [] })
57
56
 
58
57
  session.interest(rootKey)
59
58
  session.announce(myKey, rootKey)
@@ -78,7 +77,10 @@ joinBtn.onclick = async () => {
78
77
  }
79
78
 
80
79
  function watchRepo (keyHex, repo) {
81
- repo.watch(`chat:${keyHex}`, triggerRender)
80
+ repo.watch(`chat:${keyHex}`, () => {
81
+ repo.byteLength // register 'length' dep → re-fires on every commit and incoming sync chunk
82
+ triggerRender()
83
+ })
82
84
  }
83
85
 
84
86
  for (const [k, r] of registry) watchRepo(k, r)
@@ -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
+ }
@@ -65,7 +65,7 @@ describe(import.meta.url, ({ test }) => {
65
65
  wss.close()
66
66
  })
67
67
 
68
- test('two origins converge on the same state', async ({ assert }) => {
68
+ test('two origins converge on the same byte stream', async ({ assert }) => {
69
69
  const serverRegistry = new StreamRegistry()
70
70
  const wss = outletSync(serverRegistry, 0)
71
71
  await new Promise(resolve => wss.on('listening', resolve))
@@ -77,12 +77,18 @@ describe(import.meta.url, ({ test }) => {
77
77
  const s2 = await r2.open(KEY)
78
78
 
79
79
  s1.set({ x: 1 })
80
- s2.set({ x: 2 }) // will be a conflict at the streamo level, but both chunks land
81
-
80
+ s2.set({ x: 2 })
81
+
82
+ // NOTE: these are bare Streamos, not Repos — no commit records, no parent
83
+ // pointers. The two streams have conflicting chunks at the same byte
84
+ // offsets; both arrive at the server and each other via dedup-append.
85
+ // byteLength convergence is all we can assert here: the merged stream
86
+ // contains all unique chunks from both writers but the second writer's
87
+ // value address is no longer valid in the merged layout. This is a known
88
+ // limitation; see ROADMAP "multi-device write conflict detection".
82
89
  const ws1 = await originSync(s1, KEY, 'localhost', port)
83
90
  const ws2 = await originSync(s2, KEY, 'localhost', port)
84
91
 
85
- // Both clients should end up with the same byteLength once chunks propagate
86
92
  const serverStream = await serverRegistry.open(KEY)
87
93
  await waitFor(serverStream, s => s.byteLength >= s1.byteLength && s.byteLength >= s2.byteLength)
88
94
  await waitFor(s1, s => s.byteLength >= serverStream.byteLength)
@@ -95,4 +101,44 @@ describe(import.meta.url, ({ test }) => {
95
101
  for (const c of wss.clients) c.terminate()
96
102
  wss.close()
97
103
  })
104
+
105
+ test('relay forwards data between server and client without writing its own commits', async ({ assert }) => {
106
+ // Server
107
+ const serverRegistry = new StreamRegistry()
108
+ const serverStream = await serverRegistry.open(KEY)
109
+ serverStream.set({ hello: 'from-server' })
110
+ const serverWss = outletSync(serverRegistry, 0)
111
+ await new Promise(resolve => serverWss.on('listening', resolve))
112
+ const serverPort = serverWss.address().port
113
+
114
+ // Relay: originSync upstream to server, outletSync downstream for clients.
115
+ // The relay never calls set() or commit() — it only accumulates and re-serves
116
+ // the byte stream it receives.
117
+ const relayRegistry = new StreamRegistry()
118
+ const relayStream = await relayRegistry.open(KEY)
119
+ await originSync(relayStream, KEY, 'localhost', serverPort)
120
+ const relayWss = outletSync(relayRegistry, 0)
121
+ await new Promise(resolve => relayWss.on('listening', resolve))
122
+ const relayPort = relayWss.address().port
123
+
124
+ // Client connects to relay only — no direct server connection
125
+ const clientRegistry = new StreamRegistry()
126
+ const clientStream = await clientRegistry.open(KEY)
127
+ const clientWs = await originSync(clientStream, KEY, 'localhost', relayPort)
128
+
129
+ // Server data reaches client via relay
130
+ await waitFor(clientStream, s => s.get('hello') === 'from-server')
131
+ assert.equal(clientStream.get('hello'), 'from-server', 'relay forwarded server data to client')
132
+
133
+ // Client data propagates back through relay to server
134
+ clientStream.set({ hello: 'from-client' })
135
+ await waitFor(serverStream, s => s.get('hello') === 'from-client')
136
+ assert.equal(serverStream.get('hello'), 'from-client', 'relay forwarded client data to server')
137
+
138
+ clientWs.close()
139
+ for (const c of relayWss.clients) c.terminate()
140
+ relayWss.close()
141
+ for (const c of serverWss.clients) c.terminate()
142
+ serverWss.close()
143
+ })
98
144
  })