@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.
- package/.claude/settings.json +1 -0
- package/.claude/settings.local.json +3 -1
- package/.env.dev +4 -0
- package/CLAUDE.md +73 -0
- package/README.md +44 -22
- package/ROADMAP.md +81 -50
- package/bin/streamo.js +24 -4
- package/jsconfig.json +3 -2
- package/package.json +4 -3
- package/public/apps/chat/main.js +70 -87
- package/public/streamo/Repo.js +36 -1
- package/public/streamo/StreamoComponent.js +92 -0
- package/public/streamo/chat-cli.js +4 -3
- package/public/streamo/mount.js +69 -20
- package/public/streamo/webSync.js +2 -2
- package/public/streamo/chat-server.js +0 -60
- package/scripts/serve.js +0 -15
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/.env.dev
ADDED
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
|
|
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
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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` —
|
|
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
|
-
###
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## toward 1.0
|
|
86
|
+
|
|
87
|
+
One thing blocking a stable `1.0` claim:
|
|
94
88
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
129
|
+
### StreamoComponent demos — shared components as content
|
|
104
130
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
126
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtudury/streamo",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
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
|
|
25
|
+
"serve": "node bin/streamo.js --env-file .env.dev --web 3000 --interactive"
|
|
25
26
|
}
|
|
26
27
|
}
|
package/public/apps/chat/main.js
CHANGED
|
@@ -1,74 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
62
|
-
}
|
|
63
|
-
rendered = all.length
|
|
64
|
-
if (all.length > rendered - 1) msgsEl.scrollTop = msgsEl.scrollHeight
|
|
21
|
+
</div>
|
|
22
|
+
`
|
|
65
23
|
}
|
|
66
24
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
41
|
+
const signer = new Signer(username, password, 1)
|
|
83
42
|
const { publicKey } = await signer.keysFor('chat')
|
|
84
|
-
myKey
|
|
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:
|
|
90
|
-
follow:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
onAnnounce:
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 = ''
|
package/public/streamo/Repo.js
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
32
|
-
|
|
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)
|
package/public/streamo/mount.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
67
|
-
node.
|
|
68
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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}`))
|