@dtudury/streamo 0.1.1 → 0.1.2

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.
@@ -0,0 +1 @@
1
+ {}
@@ -4,7 +4,9 @@
4
4
  "Bash(git add *)",
5
5
  "Bash(git commit -m ' *)",
6
6
  "Bash(git push *)",
7
- "Bash(gh repo *)"
7
+ "Bash(gh repo *)",
8
+ "Bash(git rm *)",
9
+ "Bash(node --test)"
8
10
  ]
9
11
  }
10
12
  }
package/.env.dev ADDED
@@ -0,0 +1,4 @@
1
+ STREAMO_NAME=streamo-dev
2
+ STREAMO_USERNAME=dev
3
+ STREAMO_PASSWORD=dev
4
+ STREAMO_KEY_ITERATIONS=1
package/CLAUDE.md ADDED
@@ -0,0 +1,73 @@
1
+ # streamo — Claude context
2
+
3
+ ## what this project is
4
+
5
+ `@dtudury/streamo` is a peer-to-peer sync library with a reactive UI layer. The central
6
+ promise: no server holds authority over your data or your identity. Keys are derived
7
+ deterministically from credentials (no files to manage), every write is signed and
8
+ append-only, and the server is a relay — not a gatekeeper.
9
+
10
+ ## the face of the project
11
+
12
+ These files are how people discover and understand streamo. Keep them in sync
13
+ after any meaningful change — not as an afterthought, but as part of the work:
14
+
15
+ - **`README.md`** — npm/GitHub landing page; imports, framing, and examples must reflect
16
+ the current package name (`@dtudury/streamo`) and current capabilities
17
+ - **`public/index.html`** — browser homepage; feature list and app cards should match
18
+ the README's framing
19
+ - **`package.json`** — version, name (`@dtudury/streamo`), description, and keywords;
20
+ version bumps immediately after the user says they've published
21
+ - **`ROADMAP.md`** — public on GitHub; mark items done when they ship, update "start
22
+ here" to the next priority, keep the "toward 1.0" list current
23
+
24
+ Stale public-facing docs erode trust faster than bugs do.
25
+
26
+ ## publish rhythm
27
+
28
+ The user publishes to npm manually and notifies Claude when done. The moment they say
29
+ they've published, bump the patch version in `package.json`, commit, and push — before
30
+ starting any other work. This ritual matters to them.
31
+
32
+ ## language and framing
33
+
34
+ Use this framing consistently across all public-facing text:
35
+
36
+ 1. **No server holds authority** — the server is a relay, not a gatekeeper
37
+ 2. **Your identity travels with you** — same credentials, same keypair, everywhere; no
38
+ key files, no seed phrases, no backup ritual
39
+ 3. **Every write is provably yours** — signed commits, append-only, permanent
40
+ 4. **Content-addressed** — data identified by what it is, not where it lives
41
+
42
+ "Content-addressed" is technically important but not the lead. Start with ownership.
43
+
44
+ ## architecture notes
45
+
46
+ - `Streamo` — content-addressed, append-only byte store with self-describing codec
47
+ - `Repo` — wraps Streamo; every `set()` is a signed commit (message, date, address, parent)
48
+ - `Signer` — deterministic secp256k1 keypairs via PBKDF2 from username + password
49
+ - `Recaller` — fine-grained reactive dependency tracker; `watch(name, f)` / `unwatch(f)`
50
+ - `h` — tagged template literal HTML parser → HElement / HText virtual tree
51
+ - `mount` — reactive DOM renderer; slots are reactive cells; elements recycled by
52
+ `data-key` then tag on re-render; removed nodes cleaned up via `recaller.unwatch()`;
53
+ exports `dismount(root, recaller)` for custom element cleanup
54
+ - `StreamoComponent` — base class for hot-reloadable custom elements; `componentKey`
55
+ generates address-based names; `defineComponent` registers render functions; function
56
+ components `(props) => nodes` work directly as tags in `h` with no class needed
57
+ - `registrySync` — bidirectional multi-repo sync over a single WebSocket; works in Node
58
+ and browser; content-driven discovery via `follow`
59
+ - CLI `--chat-room` flag — when combined with `--web`, auto-accepts member announcements
60
+ and stores the member list in the server's own repo (backed by `archiveSync`);
61
+ the server's public key is the room key; `chat-server.js` is retired
62
+
63
+ ## what's next (toward 1.0)
64
+
65
+ 1. Chat signing — wire `repo.sign()` so messages are cryptographically verified
66
+ 2. Rebuild the browser app with `h` / `mount`
67
+
68
+ ## commit style
69
+
70
+ Commit and push at the end of every response that makes a change. Over-commit rather
71
+ than over-think. Co-author line on every commit:
72
+
73
+ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
package/README.md CHANGED
@@ -2,25 +2,25 @@
2
2
 
3
3
  > every device is an equal author
4
4
 
5
- Streamo is a content-addressed, cryptographically signed, peer-to-peer sync library. There is no central server your keypair is your identity, your commit log is the source of truth, and every connected peer sees the same history.
5
+ Streamo is a peer-to-peer sync library built around a simple promise: **no server holds authority over your data or your identity.** The server is a relay, not a gatekeeper. Your keypair is your identity — derived from your credentials, not stored in a file. Your commit log is the source of truth, and every connected peer sees the same history.
6
6
 
7
7
  ## core ideas
8
8
 
9
- - **Content-addressed** — data is identified by what it is, not where it lives. The same value always lands at the same address; deduplication and diffing are free.
10
- - **Signed** every write is authenticated with a secp256k1 keypair derived from your username and password. Peers reject unsigned or mis-signed data.
11
- - **Append-only** — history is never rewritten. Every commit is permanent and verifiable.
12
- - **P2P sync** — repos replicate over WebSocket. The server is just another peer; disconnect it and the data is still yours.
9
+ - **No server holds authority** — the server is a relay; your data lives on your devices and can't be seized or censored. Disconnect the server and everything is still yours.
10
+ - **Your identity travels with you** keys are derived with PBKDF2 from your username and password. Same credentials, same keypair, everywhere — no key files, no seed phrases, no backup ritual.
11
+ - **Every write is provably yours** — commits are signed with your keypair and append-only. History is permanent and can't be forged; peers reject unsigned or mis-signed data.
12
+ - **Content-addressed** — data is identified by what it is, not where it lives. The same value always lands at the same address; deduplication and diffing are structural.
13
13
 
14
14
  ## install
15
15
 
16
16
  ```bash
17
- npm install streamo
17
+ npm install @dtudury/streamo
18
18
  ```
19
19
 
20
20
  Or run the CLI directly:
21
21
 
22
22
  ```bash
23
- npx streamo --help
23
+ npx @dtudury/streamo --help
24
24
  ```
25
25
 
26
26
  ## cli
@@ -50,14 +50,15 @@ 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 |
53
54
 
54
55
  ## javascript api
55
56
 
56
57
  ### Streamo — reactive append-only store
57
58
 
58
59
  ```js
59
- import { Streamo } from 'streamo/public/streamo/Streamo.js'
60
- import { Recaller } from 'streamo/public/streamo/utils/Recaller.js'
60
+ import { Streamo } from '@dtudury/streamo/public/streamo/Streamo.js'
61
+ import { Recaller } from '@dtudury/streamo/public/streamo/utils/Recaller.js'
61
62
 
62
63
  const store = new Streamo()
63
64
  store.set({ name: 'alice', score: 42 })
@@ -72,20 +73,23 @@ Values are encoded with a self-describing codec (strings, numbers, dates, boolea
72
73
  `Repo` wraps a `Streamo` so every `set()` becomes a commit — message, date, data address, and parent pointer. The raw commit log is what syncs over the wire.
73
74
 
74
75
  ```js
75
- import { Repo } from 'streamo/public/streamo/Repo.js'
76
+ import { Repo } from '@dtudury/streamo/public/streamo/Repo.js'
76
77
 
77
78
  const repo = new Repo()
79
+ repo.attachSigner(signer, 'my-dataset') // auto-sign every commit
78
80
  repo.set({ name: 'alice', messages: [] })
79
81
  repo.get('name') // 'alice'
80
82
  repo.lastCommit // { message: '', date: Date, dataAddress: n, parent: n|undefined }
81
83
  [...repo.history()] // newest-first iterator over commits
82
84
  ```
83
85
 
86
+ Signature chunks travel in the byte stream automatically — peers running `registrySync` or `originSync` verify every signature on receipt and reject data that doesn't match the repo's public key.
87
+
84
88
  ### Signer — deterministic identity
85
89
 
86
90
  ```js
87
- import { Signer } from 'streamo/public/streamo/Signer.js'
88
- import { bytesToHex } from 'streamo/public/streamo/utils.js'
91
+ import { Signer } from '@dtudury/streamo/public/streamo/Signer.js'
92
+ import { bytesToHex } from '@dtudury/streamo/public/streamo/utils.js'
89
93
 
90
94
  const signer = new Signer('alice', 'my-password')
91
95
  const { publicKey } = await signer.keysFor('my-dataset')
@@ -97,8 +101,8 @@ Keys are derived with PBKDF2 so the same username + password always produces the
97
101
  ### RepoRegistry — multi-repo store
98
102
 
99
103
  ```js
100
- import { RepoRegistry } from 'streamo/public/streamo/RepoRegistry.js'
101
- import { archiveSync } from 'streamo/public/streamo/archiveSync.js'
104
+ import { RepoRegistry } from '@dtudury/streamo/public/streamo/RepoRegistry.js'
105
+ import { archiveSync } from '@dtudury/streamo/public/streamo/archiveSync.js'
102
106
 
103
107
  const registry = new RepoRegistry(async key => {
104
108
  const repo = new Repo()
@@ -112,7 +116,7 @@ const repo = await registry.open(publicKeyHex)
112
116
  ### registrySync — peer sync over WebSocket
113
117
 
114
118
  ```js
115
- import { registrySync } from 'streamo/public/streamo/registrySync.js'
119
+ import { registrySync } from '@dtudury/streamo/public/streamo/registrySync.js'
116
120
 
117
121
  const session = await registrySync(registry, 'localhost', 8080, {
118
122
  // only sync repos you care about
@@ -134,9 +138,9 @@ session.announce(myKey, rootKey) // tell interested peers about your repo
134
138
  ### h + mount — reactive UI
135
139
 
136
140
  ```js
137
- import { h } from 'streamo/public/streamo/h.js'
138
- import { mount } from 'streamo/public/streamo/mount.js'
139
- import { Recaller } from 'streamo/public/streamo/utils/Recaller.js'
141
+ import { h } from '@dtudury/streamo/public/streamo/h.js'
142
+ import { mount } from '@dtudury/streamo/public/streamo/mount.js'
143
+ import { Recaller } from '@dtudury/streamo/public/streamo/utils/Recaller.js'
140
144
 
141
145
  const recaller = new Recaller('app')
142
146
 
@@ -148,7 +152,21 @@ mount(h`
148
152
  `, document.body, recaller)
149
153
  ```
150
154
 
151
- Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. No virtual DOM diffing; only the exact DOM nodes bound to changed data update.
155
+ Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. No virtual DOM diffing; only the exact DOM nodes bound to changed data update. Elements are recycled across re-renders by `data-key` (or tag as a fallback), so user input and focus survive list reorders. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
156
+
157
+ Any function can be used directly as a tag — it receives `{ ...attrs, children }` and returns virtual nodes:
158
+
159
+ ```js
160
+ import { StreamoComponent, componentKey, defineComponent } from '@dtudury/streamo/public/streamo/StreamoComponent.js'
161
+
162
+ function Card ({ title, children }) {
163
+ return h`<div class="card"><h2>${title}</h2>${children}</div>`
164
+ }
165
+
166
+ mount(h`<${Card} title="Hello"><p>hi</p></${Card}>`, document.body, recaller)
167
+ ```
168
+
169
+ For hot-reloading, `componentKey(prefix, address)` and `defineComponent(name, fn)` pair a content address to a unique custom element name. A new file version gets a new name; stale elements are naturally orphaned and cleaned up without any explicit bookkeeping.
152
170
 
153
171
  ## sync backends
154
172
 
@@ -165,8 +183,8 @@ Functions interpolated as `${() => ...}` are reactive cells — they re-run auto
165
183
  ## chat example
166
184
 
167
185
  ```bash
168
- # start the server
169
- node public/streamo/chat-server.js 8080
186
+ # start the server — its public key becomes the room key
187
+ streamo --name my-chat --username relay --web 8080 --chat-room
170
188
 
171
189
  # join from the browser
172
190
  open http://localhost:8080
@@ -175,7 +193,7 @@ open http://localhost:8080
175
193
  node public/streamo/chat-cli.js alice secret localhost 8080
176
194
  ```
177
195
 
178
- Each participant owns their own message stream. The server holds only a root repo listing members; it has no special authority over anyone's data.
196
+ Each participant owns their own message stream. The server is just another streamo node — it holds the member list in its own repo and auto-accepts anyone who announces to it. Its public key is the room address. No special authority, no hidden state.
179
197
 
180
198
  ## tests
181
199
 
@@ -189,6 +207,10 @@ node --test public/streamo/Repo.test.js # single file
189
207
  See [ROADMAP.md](./ROADMAP.md) for what's been built, what's next, and what we're
190
208
  aiming at for 1.0.
191
209
 
210
+ ## collaboration
211
+
212
+ Built with significant AI collaboration via [Claude Code](https://claude.ai/code). Human-directed; Claude is a co-author and contributor, not an autonomous builder.
213
+
192
214
  ## license
193
215
 
194
216
  AGPL-3.0-only
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.0)
8
+ ## where we are (0.1.3)
9
9
 
10
10
  The foundation is solid and working. Here's what's in:
11
11
 
@@ -14,7 +14,8 @@ The foundation is solid and working. Here's what's in:
14
14
  self-describing codec. Same value always encodes to the same bytes; dedup and
15
15
  diffing are free.
16
16
  - `Repo` — every write is a signed commit. Message, date, data address, parent.
17
- The full history is always there.
17
+ The full history is always there. `attachSigner(signer, name)` enables
18
+ automatic signing after every commit; concurrent commits are batched safely.
18
19
  - `Signer` — deterministic secp256k1 keypairs from username + password via PBKDF2.
19
20
  No key files to manage; same credentials always produce the same identity.
20
21
  - `Recaller` — fine-grained reactive dependency tracker. Watchers re-run only when
@@ -32,51 +33,42 @@ The foundation is solid and working. Here's what's in:
32
33
  any persistence.
33
34
 
34
35
  **UI layer**
35
- - `h` — tagged template literal parser. Turns `h\`<div class=${cls}>...\`` into a
36
+ - `h` — tagged template literal parser. Turns `` h`<div class=${cls}>...` `` into a
36
37
  virtual tree of `HElement` / `HText` / slot nodes.
37
38
  - `mount` — reactive DOM renderer. Slots that are functions re-run automatically
38
39
  when the data they read changes. No virtual DOM diffing — only the exact nodes
39
- bound to mutated paths update.
40
+ bound to mutated paths update. Watcher cleanup is precise: removed nodes are
41
+ unwatched before removal so watchers never accumulate. Elements are recycled
42
+ across re-renders by `data-key` (exact) then tag (positional fallback), so user
43
+ input and focus survive list reorders. SVG namespaces propagate automatically —
44
+ `` h`<svg><path/></svg>` `` just works. `class` accepts an array
45
+ (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`).
46
+ - `StreamoComponent` — base class for hot-reloadable custom element components.
47
+ Function components (`(props) => nodes`) work directly as tags in `h`. For
48
+ hot-reloading, `componentKey(prefix, address)` and `defineComponent(name, fn)`
49
+ pair a content address to a unique custom element name — a new file version gets
50
+ a new name, stale elements are naturally orphaned and cleaned up.
40
51
 
41
52
  **Apps**
42
- - Chat — full p2p messaging app. Each participant owns their own message stream;
43
- the server is just a relay and holds no special authority. Runs in the browser
44
- and from the terminal (`chat-cli.js`).
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`).
45
58
  - Homepage at `public/index.html`.
46
- - `npm run serve` — static file server for `public/`.
59
+ - `npm run serve` — starts a streamo node (with REPL) using `.env.dev`
60
+ credentials. The dev server is a real peer, not a bare static file server.
47
61
 
48
62
  ---
49
63
 
50
64
  ## what's next
51
65
 
52
- ### fix watcher leaks in `mount` ← start here
53
- When a reactive slot re-renders, child watchers registered inside it are never
54
- cleaned up. Every re-render accumulates more. This is a correctness bug and the
55
- thing most worth fixing before building more UI on top of `mount`.
56
-
57
- ### component support in `h`
58
- Functions as tags: `h\`<${Card} title="hi"/>\``. This is the difference between
59
- a templating tool and a UI framework. Once watcher cleanup exists, components
60
- become straightforward — a component is just a function that returns nodes and
61
- cleans up after itself.
62
-
63
- ### SVG namespace
64
- `mount` hardcodes the XHTML namespace. `h\`<svg><path/></svg>\`` won't render
65
- correctly until `mount` auto-detects SVG elements and switches namespaces.
66
-
67
- ### `class` as array or object
68
- `class=${['btn', isActive && 'active']}` is such a common pattern that not
69
- supporting it is a daily papercut. Easy win.
70
-
71
- ### chat persistence
72
- Right now the chat server is in-memory — restart it and history is gone. Wiring
73
- `archiveSync` into `chat-server.js` is a small change with a big quality-of-life
74
- improvement.
75
-
76
- ### chat signing
77
- Messages aren't cryptographically verified yet. Anyone who knows a participant's
78
- public key hex could theoretically spoof them. Wiring `repo.sign()` after each
79
- `set()` closes this.
66
+ ### 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.
80
72
 
81
73
  ### presence indicators
82
74
  Who's currently online? The `interest` / `announce` layer is ephemeral by design,
@@ -88,24 +80,63 @@ The old repository-browser app was left behind during the migration because its
88
80
  imports broke. Rebuilding it with `h` / `mount` would be the first substantial
89
81
  real-world test of the UI layer.
90
82
 
91
- ### fix dead links on the homepage
92
- `public/index.html` links to the browser and components apps that no longer exist.
93
- Either rebuild them or remove the links.
83
+ ---
84
+
85
+ ## toward 1.0
86
+
87
+ One thing blocking a stable `1.0` claim:
94
88
 
95
- ### CLAUDE.md
96
- Add a `CLAUDE.md` for the streamo repo so future Claude sessions have the full
97
- context.
89
+ 1. **Chat persistence** — a chat app that loses history on restart isn't production-ready
90
+
91
+ Chat signing is done. Components, keyed list reconciliation, SVG namespaces,
92
+ `class` arrays/objects, and the CLI server unification are all done.
93
+ Persistence is the last mile.
98
94
 
99
95
  ---
100
96
 
101
- ## toward 1.0
97
+ ## beyond 1.0
98
+
99
+ Ideas that follow naturally from the architecture but aren't blocking anything.
100
+
101
+ ### Claude scratchpad repos
102
+
103
+ Every streamo node already has a signed, append-only repo. A Claude session
104
+ could write observations, notes, and work products to that repo during a
105
+ conversation — and the owner could watch them appear live in a browser via
106
+ `mount`. Between sessions, Claude reads the repo to reconstruct context
107
+ instead of relying on static memory files. The work is persistent and
108
+ provably Claude's, with the same integrity guarantees as any other streamo data.
109
+
110
+ ### Claude-to-Claude networks
111
+
112
+ If each person's Claude has a scratchpad repo, those repos can sync the same
113
+ way any other repos do. The `follow` callback in `registrySync` already handles
114
+ content-driven discovery — subscribe to a member list, auto-follow everyone on
115
+ it. A Claude could watch its person's friends' scratchpads, surface what's
116
+ relevant, and filter what isn't.
117
+
118
+ The interesting architectural difference from a traditional social network: there
119
+ is no central moderator. Each Claude is an advocate for its person, not a
120
+ reporter to a platform. Judgment about what to surface or filter lives at the
121
+ edge, anchored to a real signed identity. Conflicts between Claudes are just
122
+ their people having different values — which is honest in a way platform
123
+ moderation usually isn't.
124
+
125
+ A natural extension: if a Claude scratchpad includes a `StreamoComponent` for
126
+ how its notes render, other people see those notes in Claude's own layout. The
127
+ presentation travels with the content — no server controls the framing.
102
128
 
103
- The three things blocking a stable `1.0` claim:
129
+ ### StreamoComponent demos shared components as content
104
130
 
105
- 1. **Watcher leak fixed** `mount` must be correct before anyone builds on it
106
- 2. **Components** without them, `h` / `mount` is too limited for real apps
107
- 3. **Chat signing** the whole point of the project is cryptographic authorship;
108
- the flagship app should demonstrate it
131
+ `StreamoComponent` makes most sense as a post-1.0 story, after chat signing
132
+ gives the trust foundation that running someone else's component requires.
133
+ The right first demo is a **tarot deck**: each card is a `StreamoComponent`
134
+ from its designer, stored in their signed repo at a content address.
135
+ `componentKey` generates a stable element name from that address. A reading
136
+ is a snapshot — cards freeze at the version they were drawn, which is a
137
+ feature, not a bug. The designer's signed key is provenance.
109
138
 
110
- Keyed list reconciliation and refs are quality-of-life improvements that can come
111
- after 1.0.
139
+ Other directions once the pattern is established: publisher-controlled article
140
+ cards that travel with syndicated content (the layout is the author's, not
141
+ the platform's); collaborative maps where each participant's marker is their
142
+ own component; shared instrument components in a live music session.
package/bin/streamo.js CHANGED
@@ -91,6 +91,10 @@ program
91
91
  new Option('--interactive', 'start a REPL with streamo, signer, and helpers as globals')
92
92
  .env('STREAMO_INTERACTIVE')
93
93
  )
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
+ )
94
98
  .addOption(
95
99
  new Option('--key-iterations <number>', 'PBKDF2 iterations for key derivation (lower = faster startup, less secure)')
96
100
  .env('STREAMO_KEY_ITERATIONS')
@@ -122,9 +126,8 @@ const publicKeyHex = Array.from(publicKey).map(b => b.toString(16).padStart(2, '
122
126
 
123
127
  const name = options.name
124
128
  const username = options.username
125
- const appPath = options.envFile
126
- ? '/' + dirname(options.envFile).replace(/^public\//, '') + '/'
127
- : '/'
129
+ const envDir = options.envFile ? dirname(options.envFile).replace(/^public\//, '') : null
130
+ const appPath = (envDir && envDir !== '.') ? `/${envDir}/` : '/'
128
131
  const webUrl = options.web ? `http://localhost:${+options.web}${appPath}` : null
129
132
  const rows = [
130
133
  ['NAME', name],
@@ -152,6 +155,7 @@ const registry = new RepoRegistry(async key => {
152
155
  return repo
153
156
  })
154
157
  const streamo = await registry.open(publicKeyHex)
158
+ streamo.attachSigner(signer, name)
155
159
 
156
160
  if (options.files) {
157
161
  const folder = typeof options.files === 'string' ? options.files : '.'
@@ -175,8 +179,24 @@ if (options.s3Bucket) {
175
179
  console.log(`\x1b[32ms3: syncing to bucket ${options.s3Bucket}\x1b[0m`)
176
180
  }
177
181
 
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
+
178
198
  if (options.web) {
179
- await webSync(registry, publicKeyHex, +options.web, name, options.keyIterations)
199
+ await webSync(registry, publicKeyHex, +options.web, name, options.keyIterations, peerOptions)
180
200
  }
181
201
 
182
202
  if (options.outlet) {
package/jsconfig.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
- "module": "ES2022",
5
- "moduleResolution": "node",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "node16",
6
+ "moduleResolution": "node16",
6
7
  "checkJs": false
7
8
  },
8
9
  "exclude": ["node_modules"]
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "0.1.1",
4
- "description": "content-addressed p2p sync",
3
+ "version": "0.1.2",
4
+ "description": "peer-to-peer sync where your data and identity belong to you, not the server",
5
+ "keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
5
6
  "repository": "git@github.com:dtudury/streamo.git",
6
7
  "author": "David Tudury <david.tudury@gmail.com>",
7
8
  "license": "AGPL-3.0-only",
@@ -21,6 +22,6 @@
21
22
  "ws": "^8.18.3"
22
23
  },
23
24
  "scripts": {
24
- "serve": "node scripts/serve.js"
25
+ "serve": "node bin/streamo.js --env-file .env.dev --web 3000 --interactive"
25
26
  }
26
27
  }
@@ -1,74 +1,33 @@
1
- import { Signer } from '/streamo/Signer.js'
2
- import { RepoRegistry } from '/streamo/RepoRegistry.js'
3
- import { registrySync } from '/streamo/registrySync.js'
4
- import { bytesToHex } from '/streamo/utils.js'
1
+ import { h } from '../../streamo/h.js'
2
+ import { mount } from '../../streamo/mount.js'
3
+ import { Recaller } from '../../streamo/utils/Recaller.js'
4
+ import { Signer } from '../../streamo/Signer.js'
5
+ import { RepoRegistry } from '../../streamo/RepoRegistry.js'
6
+ import { registrySync } from '../../streamo/registrySync.js'
7
+ import { bytesToHex } from '../../streamo/utils.js'
5
8
 
6
- const loginEl = document.getElementById('login')
7
- const chatEl = document.getElementById('chat')
8
- const statusEl = document.getElementById('status')
9
- const myNameEl = document.getElementById('my-name')
10
- const msgsEl = document.getElementById('messages')
11
- const inputEl = document.getElementById('msg-input')
12
- const sendBtn = document.getElementById('send-btn')
13
- const joinBtn = document.getElementById('join-btn')
14
-
15
- const { rootKey } = await fetch('/api/chat-info').then(r => r.json())
16
-
17
- // ── Helpers ────────────────────────────────────────────────────────────────
9
+ const { primaryKeyHex: rootKey } = await fetch('/api/info').then(r => r.json())
18
10
 
19
11
  function fmt (ts) {
20
12
  return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
21
13
  }
22
14
 
23
- // ── Rendering ──────────────────────────────────────────────────────────────
24
-
25
- let myKey = null
26
-
27
- /** Flat list of { name, text, at, mine } sorted by `at` */
28
- function collectMessages (registry) {
29
- const all = []
30
- for (const [keyHex, repo] of registry) {
31
- const name = repo.get('name')
32
- const messages = repo.get('messages') ?? []
33
- for (const msg of messages) {
34
- const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
35
- const at = msg?.at ?? 0
36
- all.push({ name, text, at, mine: keyHex === myKey })
37
- }
38
- }
39
- all.sort((a, b) => a.at - b.at)
40
- return all
41
- }
42
-
43
- let rendered = 0
44
-
45
- function renderMessages (registry) {
46
- const all = collectMessages(registry)
47
- // Only append new messages (simple: clear + rebuild if out of order, else append)
48
- if (all.length < rendered) {
49
- msgsEl.innerHTML = ''
50
- rendered = 0
51
- }
52
- for (let i = rendered; i < all.length; i++) {
53
- const { name, text, at, mine } = all[i]
54
- const div = document.createElement('div')
55
- div.className = `msg ${mine ? 'mine' : 'theirs'}`
56
- div.innerHTML = `
57
- ${!mine ? `<div class="sender">${escHtml(name)}</div>` : ''}
58
- <div class="text">${escHtml(text)}</div>
15
+ function Msg ({ name, text, at, mine }) {
16
+ return h`
17
+ <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
18
+ ${!mine ? h`<div class="sender">${name}</div>` : null}
19
+ <div class="text">${text}</div>
59
20
  <div class="time">${fmt(at)}</div>
60
- `
61
- msgsEl.appendChild(div)
62
- }
63
- rendered = all.length
64
- if (all.length > rendered - 1) msgsEl.scrollTop = msgsEl.scrollHeight
21
+ </div>
22
+ `
65
23
  }
66
24
 
67
- function escHtml (s) {
68
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
69
- }
70
-
71
- // ── Join ───────────────────────────────────────────────────────────────────
25
+ const loginEl = document.getElementById('login')
26
+ const chatEl = document.getElementById('chat')
27
+ const msgsEl = document.getElementById('messages')
28
+ const inputEl = document.getElementById('msg-input')
29
+ const statusEl = document.getElementById('status')
30
+ const joinBtn = document.getElementById('join-btn')
72
31
 
73
32
  joinBtn.onclick = async () => {
74
33
  const username = document.getElementById('username').value.trim()
@@ -79,50 +38,74 @@ joinBtn.onclick = async () => {
79
38
  statusEl.textContent = 'connecting…'
80
39
 
81
40
  try {
82
- const signer = new Signer(username, password, 1)
41
+ const signer = new Signer(username, password, 1)
83
42
  const { publicKey } = await signer.keysFor('chat')
84
- myKey = bytesToHex(publicKey)
85
-
43
+ const myKey = bytesToHex(publicKey)
86
44
  const registry = new RepoRegistry()
87
45
 
88
46
  const session = await registrySync(registry, location.hostname, Number(location.port) || 80, {
89
- filter: k => k === rootKey,
90
- follow: (keyHex, repo, subscribe) => {
91
- for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
92
- },
93
- onAnnounce: (key) => {
94
- session.subscribe(key)
95
- }
47
+ filter: k => k === rootKey,
48
+ follow: (keyHex, repo, subscribe) => {
49
+ for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
50
+ },
51
+ onAnnounce: key => session.subscribe(key)
96
52
  })
97
53
 
98
- // Open own repo
99
54
  const myRepo = await registry.open(myKey)
100
- if (!myRepo.get('name')) {
101
- myRepo.set({ name: username, messages: [] })
102
- }
55
+ myRepo.attachSigner(signer, 'chat')
56
+ if (!myRepo.get('name')) myRepo.set({ name: username, messages: [] })
103
57
 
104
58
  session.interest(rootKey)
105
59
  session.announce(myKey, rootKey)
106
60
 
107
- // Switch to chat view
108
61
  loginEl.style.display = 'none'
109
- chatEl.style.display = 'flex'
110
- myNameEl.textContent = `(${username})`
62
+ chatEl.style.display = 'flex'
63
+ document.getElementById('my-name').textContent = `(${username})`
64
+
65
+ // ── Reactive message list ──────────────────────────────────────────────
66
+ //
67
+ // Each repo has its own internal Recaller, so repo.get() inside a mount
68
+ // slot won't automatically re-trigger mount's recaller. Bridge via
69
+ // reportKey*: repo.watch() calls reportKeyMutation when data changes;
70
+ // the slot calls reportKeyAccess to register the dependency.
71
+
72
+ const recaller = new Recaller('chat')
73
+ const signal = {}
74
+
75
+ function triggerRender () {
76
+ recaller.reportKeyMutation(signal, 'data')
77
+ requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
78
+ }
111
79
 
112
- // Reactive rendering: re-render on any repo change
113
80
  function watchRepo (keyHex, repo) {
114
- repo.watch(`chat-render:${keyHex}`, () => renderMessages(registry))
81
+ repo.watch(`chat:${keyHex}`, triggerRender)
115
82
  }
83
+
116
84
  for (const [k, r] of registry) watchRepo(k, r)
117
- registry.onOpen((keyHex, repo) => {
118
- watchRepo(keyHex, repo)
119
- renderMessages(registry)
120
- })
121
- renderMessages(registry)
85
+ registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
86
+
87
+ mount(h`${function messages () {
88
+ recaller.reportKeyAccess(signal, 'data')
89
+ const all = []
90
+ for (const [keyHex, repo] of registry) {
91
+ if (keyHex === rootKey) continue
92
+ const name = repo.get('name')
93
+ for (const msg of repo.get('messages') ?? []) {
94
+ const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
95
+ const at = msg?.at ?? 0
96
+ all.push({ name, text, at, mine: keyHex === myKey })
97
+ }
98
+ }
99
+ all.sort((a, b) => a.at - b.at)
100
+ return all.map(({ name, text, at, mine }) =>
101
+ h`<${Msg} name=${name} text=${text} at=${at} mine=${mine}/>`)
102
+ }}`, msgsEl, recaller)
122
103
 
123
104
  // ── Send ────────────────────────────────────────────────────────────────
124
105
 
125
- async function sendMessage () {
106
+ const sendBtn = document.getElementById('send-btn')
107
+
108
+ function sendMessage () {
126
109
  const text = inputEl.value.trim()
127
110
  if (!text) return
128
111
  inputEl.value = ''
@@ -18,6 +18,39 @@ import { Streamo, changedPaths } from './Streamo.js'
18
18
  * for read-only inspection or direct use with the explicit commit() API.
19
19
  */
20
20
  export class Repo extends Streamo {
21
+ #signer = null
22
+ #signerName = null
23
+ #signing = false
24
+ #signPending = false
25
+
26
+ /**
27
+ * Attach a signer so every commit is automatically signed.
28
+ * Concurrent commits are batched: if a sign is in flight when another
29
+ * commit lands, one more sign runs after the current one finishes,
30
+ * covering all accumulated commits in a single signature.
31
+ *
32
+ * @param {import('./Signer.js').Signer} signer
33
+ * @param {string} name stream name passed to signer.keysFor()
34
+ */
35
+ attachSigner (signer, name) {
36
+ this.#signer = signer
37
+ this.#signerName = name
38
+ }
39
+
40
+ #scheduleSign () {
41
+ if (!this.#signer) return
42
+ if (this.#signing) { this.#signPending = true; return }
43
+ this.#signing = true
44
+ this.sign(this.#signer, this.#signerName)
45
+ .then(() => {
46
+ this.#signing = false
47
+ if (this.#signPending) {
48
+ this.#signPending = false
49
+ this.#scheduleSign()
50
+ }
51
+ })
52
+ .catch(console.error)
53
+ }
21
54
  /**
22
55
  * The latest commit record, or null if nothing has been committed yet.
23
56
  * Registers a reactive dependency on the commit log length.
@@ -171,6 +204,8 @@ export class Repo extends Streamo {
171
204
  const parent = parentAddr >= 0 ? parentAddr : undefined
172
205
  const dataAddress = this.copyFrom(workingStreamo, workingStreamo.byteLength - 1)
173
206
  const code = this.encode({ message, date: new Date(), dataAddress, parent })
174
- return this.append(code)
207
+ const result = this.append(code)
208
+ this.#scheduleSign()
209
+ return result
175
210
  }
176
211
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * StreamoComponent — base class for hot-reloadable custom element components
3
+ *
4
+ * Two levels of component in streamo:
5
+ *
6
+ * 1. Function components — plain functions used directly as tags in h``:
7
+ *
8
+ * function Card ({ title, children }) {
9
+ * return h`<div class="card"><h2>${title}</h2>${children}</div>`
10
+ * }
11
+ * mount(h`<${Card} title="Hello"><p>hi</p></${Card}>`, body, recaller)
12
+ *
13
+ * Attr values are passed as-is: reactive function attrs stay as functions,
14
+ * so the component can forward them straight into its own slots.
15
+ *
16
+ * 2. Custom element components (this file) — for hot-reloading via content
17
+ * addresses. Each file version gets a unique element name; stale elements
18
+ * become orphans (different tag → no recycling → cleaned up automatically).
19
+ *
20
+ * Typical pattern:
21
+ *
22
+ * // When the address of Card.js changes in the reactive store:
23
+ * const cardTag = () => {
24
+ * const addr = repo.get('components.card')
25
+ * if (!addr) return null
26
+ * return defineComponent(componentKey('s-card', addr), ({ title }) =>
27
+ * h`<div class="card"><h2>${title}</h2></div>`
28
+ * )
29
+ * }
30
+ * mount(h`${() => { const t = cardTag(); return t && h`<${t} title="Hello"/>` }}`, body, recaller)
31
+ */
32
+
33
+ import { Recaller } from './utils/Recaller.js'
34
+ import { mount, dismount } from './mount.js'
35
+
36
+ export class StreamoComponent extends HTMLElement {
37
+ #recaller
38
+ #root
39
+
40
+ connectedCallback () {
41
+ this.#recaller = new Recaller(this.localName)
42
+ this.#root = this.attachShadow({ mode: 'open' })
43
+ mount(this.render(this.#buildProps()), this.#root, this.#recaller)
44
+ }
45
+
46
+ disconnectedCallback () {
47
+ if (this.#root) {
48
+ dismount(this.#root, this.#recaller)
49
+ this.#root = null
50
+ }
51
+ }
52
+
53
+ // Override in subclasses or via defineComponent.
54
+ render (props) { return [] }
55
+
56
+ #buildProps () {
57
+ const props = {}
58
+ for (const { name, value } of this.attributes) props[name] = value
59
+ props.children = [...this.childNodes]
60
+ return props
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Generate a valid custom element name from a prefix and a content address.
66
+ * The address provides uniqueness — a new address means a new element name,
67
+ * so stale elements are naturally orphaned without any explicit cleanup.
68
+ *
69
+ * @param {string} prefix e.g. 's-card' (must contain a hyphen)
70
+ * @param {string} address hex content address from the reactive store
71
+ * @returns {string} e.g. 's-card-a1b2c3d4e5f6g7h8'
72
+ */
73
+ export function componentKey (prefix, address) {
74
+ return `${prefix}-${String(address).slice(0, 16).toLowerCase()}`
75
+ }
76
+
77
+ /**
78
+ * Register a render function as a custom element. Safe to call multiple times
79
+ * with the same name — subsequent calls are no-ops.
80
+ *
81
+ * @param {string} name valid custom element name (must contain a hyphen)
82
+ * @param {Function} renderFn (props) => virtual nodes
83
+ * @returns {string} the name, for use directly as a tag
84
+ */
85
+ export function defineComponent (name, renderFn) {
86
+ if (!customElements.get(name)) {
87
+ customElements.define(name, class extends StreamoComponent {
88
+ render (props) { return renderFn(props) }
89
+ })
90
+ }
91
+ return name
92
+ }
@@ -25,11 +25,12 @@ const signer = new Signer(username, password, 1)
25
25
  const { publicKey } = await signer.keysFor('chat')
26
26
  const myKey = bytesToHex(publicKey)
27
27
 
28
- // Fetch root key from server
28
+ // Fetch root key from server (/api/info is the canonical endpoint)
29
29
  let rootKey
30
30
  try {
31
- const res = await fetch(`http://${host}:${port}/api/chat-info`)
32
- ;({ rootKey } = await res.json())
31
+ const res = await fetch(`http://${host}:${port}/api/info`)
32
+ const info = await res.json()
33
+ rootKey = info.primaryKeyHex ?? info.rootKey
33
34
  } catch (e) {
34
35
  console.error(`could not reach server at http://${host}:${port}: ${e.message}`)
35
36
  process.exit(1)
@@ -21,6 +21,9 @@
21
21
 
22
22
  import { HElement, HText } from './h.js'
23
23
 
24
+ const HTML_NS = 'http://www.w3.org/1999/xhtml'
25
+ const SVG_NS = 'http://www.w3.org/2000/svg'
26
+
24
27
  // ── Watcher cleanup registry ──────────────────────────────────────────────
25
28
  //
26
29
  // Each node tracks the watcher functions registered against it.
@@ -44,34 +47,48 @@ function cleanupNode (node, recaller) {
44
47
  for (const child of [...node.childNodes]) cleanupNode(child, recaller)
45
48
  }
46
49
 
50
+ // Call when removing a mounted root (e.g. in disconnectedCallback of a custom element).
51
+ export function dismount (root, recaller) {
52
+ cleanupNode(root, recaller)
53
+ }
54
+
47
55
  /**
48
56
  * Mount an array of virtual nodes (result of h``) into `container`.
49
- * @param {Array} nodes
57
+ * @param {Array} nodes
50
58
  * @param {Element} container
51
59
  * @param {import('./utils/Recaller.js').Recaller} recaller
60
+ * @param {string} [ns] XML namespace inherited from parent (defaults to XHTML)
52
61
  */
53
- export function mount (nodes, container, recaller) {
62
+ export function mount (nodes, container, recaller, ns = HTML_NS) {
54
63
  for (const node of [nodes].flat()) {
55
- mountNode(node, container, recaller)
64
+ mountNode(node, container, recaller, ns)
56
65
  }
57
66
  }
58
67
 
59
- function mountNode (node, container, recaller) {
68
+ function mountNode (node, container, recaller, ns = HTML_NS) {
60
69
  if (node == null) return
61
70
  if (Array.isArray(node)) {
62
- node.forEach(n => mountNode(n, container, recaller))
71
+ node.forEach(n => mountNode(n, container, recaller, ns))
63
72
  return
64
73
  }
65
74
  if (node instanceof HElement) {
66
- const el = document.createElementNS(
67
- node.attrs.find(a => a?.name === 'xmlns')?.value ?? 'http://www.w3.org/1999/xhtml',
68
- node.tag
69
- )
75
+ if (typeof node.tag === 'function') {
76
+ mountNode(node.tag(buildProps(node)), container, recaller, ns)
77
+ return
78
+ }
79
+ // Determine this element's namespace:
80
+ // xmlns attr > svg tag > foreignObject resets to HTML > inherit from parent
81
+ const nsAttr = node.attrs.find(a => a?.name === 'xmlns')?.value
82
+ const elemNs = nsAttr
83
+ ?? (node.tag === 'svg' ? SVG_NS
84
+ : node.tag === 'foreignObject' ? HTML_NS
85
+ : ns)
86
+ const el = document.createElementNS(elemNs, node.tag)
70
87
  for (const attr of node.attrs) {
71
88
  if (attr == null) continue
72
89
  applyAttr(el, attr, recaller)
73
90
  }
74
- mount(node.children, el, recaller)
91
+ mount(node.children, el, recaller, elemNs)
75
92
  container.appendChild(el)
76
93
  return
77
94
  }
@@ -84,7 +101,7 @@ function mountNode (node, container, recaller) {
84
101
  return
85
102
  }
86
103
  if (typeof node === 'function') {
87
- mountSlot(node, container, recaller)
104
+ mountSlot(node, container, recaller, ns)
88
105
  return
89
106
  }
90
107
  // primitive — string, number, etc.
@@ -98,7 +115,7 @@ function mountNode (node, container, recaller) {
98
115
  // elements are matched by data-key (exact) or tag (positional fallback)
99
116
  // and recycled in place. Unmatched nodes are cleaned up before removal.
100
117
 
101
- function mountSlot (cell, container, recaller) {
118
+ function mountSlot (cell, container, recaller, ns = HTML_NS) {
102
119
  const start = document.createComment('')
103
120
  const end = document.createComment('')
104
121
  container.appendChild(start)
@@ -106,13 +123,13 @@ function mountSlot (cell, container, recaller) {
106
123
 
107
124
  const watcher = () => {
108
125
  const newVNodes = [cell(container)].flat(Infinity).filter(n => n != null)
109
- reconcileSlot(start, end, newVNodes, recaller)
126
+ reconcileSlot(start, end, newVNodes, recaller, ns)
110
127
  }
111
128
  addCleanup(start, watcher)
112
129
  recaller.watch(cell.name || '(h cell)', watcher)
113
130
  }
114
131
 
115
- function reconcileSlot (start, end, newVNodes, recaller) {
132
+ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
116
133
  // Collect existing Element nodes between the anchors — only elements can be recycled
117
134
  const existingEls = []
118
135
  let node = start.nextSibling
@@ -183,7 +200,7 @@ function reconcileSlot (start, end, newVNodes, recaller) {
183
200
  end.before(recycled)
184
201
  } else {
185
202
  const frag = document.createDocumentFragment()
186
- mountNode(vnode, frag, recaller)
203
+ mountNode(vnode, frag, recaller, ns)
187
204
  end.before(frag)
188
205
  }
189
206
  }
@@ -201,11 +218,30 @@ function patchElement (el, vnode) {
201
218
  }
202
219
  }
203
220
 
221
+ // ── Function components ───────────────────────────────────────────────────
222
+ //
223
+ // When an HElement's tag is a function, call it with a props object instead
224
+ // of creating a DOM element. Attr values are passed as-is — reactive function
225
+ // attrs stay as functions so the component can forward them into its own slots.
226
+
227
+ function buildProps (node) {
228
+ const props = {}
229
+ for (const attr of node.attrs) {
230
+ if (attr == null) continue
231
+ if (typeof attr === 'object' && attr.name) props[attr.name] = attr.value
232
+ else if (typeof attr === 'object') Object.assign(props, attr) // spread
233
+ }
234
+ props.children = node.children
235
+ return props
236
+ }
237
+
204
238
  // ── Attribute cells ───────────────────────────────────────────────────────
205
239
  //
206
240
  // attr=${cell} → cell(el), return value applied via setAttr
207
241
  // onclick=${cell} → cell(el), return value assigned to el.onclick
208
242
  // attr="prefix-${cell}" → each fn part called with el, results joined as string
243
+ // class=${[...]} → falsy items filtered, truthy items joined with space
244
+ // class=${{k:bool}} → keys with truthy values joined with space
209
245
 
210
246
  function applyAttr (el, attr, recaller) {
211
247
  if (typeof attr === 'object' && !attr.name) {
@@ -221,12 +257,17 @@ function applyAttr (el, attr, recaller) {
221
257
  return
222
258
  }
223
259
  if (Array.isArray(value)) {
224
- // mixed static/dynamic: each function part is a cell called with el
225
- const watcher = () => {
226
- setAttr(el, name, value.map(p => typeof p === 'function' ? p(el) : String(p ?? '')).join(''))
260
+ if (value.some(p => typeof p === 'function')) {
261
+ // Mixed static/dynamic attr from template interpolation — evaluate and concatenate
262
+ const watcher = () => {
263
+ setAttr(el, name, value.map(p => typeof p === 'function' ? p(el) : String(p ?? '')).join(''))
264
+ }
265
+ addCleanup(el, watcher)
266
+ recaller.watch(`attr:${name}`, watcher)
267
+ } else {
268
+ // Static array value (e.g. class list) — delegate to setAttr for normalization
269
+ setAttr(el, name, value)
227
270
  }
228
- addCleanup(el, watcher)
229
- recaller.watch(`attr:${name}`, watcher)
230
271
  return
231
272
  }
232
273
  if (value !== undefined) setAttr(el, name, value)
@@ -234,6 +275,14 @@ function applyAttr (el, attr, recaller) {
234
275
  }
235
276
 
236
277
  function setAttr (el, name, value) {
278
+ // Normalize class arrays and objects into a space-separated string
279
+ if (name === 'class') {
280
+ if (Array.isArray(value)) {
281
+ value = value.filter(Boolean).join(' ')
282
+ } else if (value !== null && value !== undefined && typeof value === 'object') {
283
+ value = Object.entries(value).filter(([, v]) => v).map(([k]) => k).join(' ')
284
+ }
285
+ }
237
286
  if (name.startsWith('on')) {
238
287
  el[name] = typeof value === 'function' ? value : null
239
288
  } else if (name === 'value' && 'value' in el) {
@@ -25,7 +25,7 @@ const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..')
25
25
  * @param {number} port
26
26
  * @returns {Promise<import('http').Server>}
27
27
  */
28
- export async function webSync (registry, primaryKeyHex, port, name, keyIterations = 100000) {
28
+ export async function webSync (registry, primaryKeyHex, port, name, keyIterations = 100000, peerOptions = {}) {
29
29
  const app = express()
30
30
 
31
31
  app.use(express.static(publicDir))
@@ -108,7 +108,7 @@ export async function webSync (registry, primaryKeyHex, port, name, keyIteration
108
108
 
109
109
  const server = createServer(app)
110
110
 
111
- attachStreamSync(new WebSocketServer({ server }), registry, 'web')
111
+ attachStreamSync(new WebSocketServer({ server }), registry, 'web', peerOptions)
112
112
 
113
113
  await new Promise((resolve, reject) => {
114
114
  server.listen(port, err => err ? reject(err) : resolve())
@@ -1,60 +0,0 @@
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
- })
package/scripts/serve.js DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Serve public/ as a static site.
4
- * Usage: node scripts/serve.js [port]
5
- */
6
- import express from 'express'
7
- import { fileURLToPath } from 'url'
8
- import { join, dirname } from 'path'
9
-
10
- const port = Number(process.argv[2] ?? process.env.PORT ?? 3000)
11
- const root = join(dirname(fileURLToPath(import.meta.url)), '../public')
12
-
13
- const app = express()
14
- app.use(express.static(root))
15
- app.listen(port, () => console.log(`http://localhost:${port}`))