@dtudury/streamo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. package/smoke.test.js +132 -0
@@ -0,0 +1,144 @@
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'
5
+
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 ────────────────────────────────────────────────────────────────
18
+
19
+ function fmt (ts) {
20
+ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
21
+ }
22
+
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>
59
+ <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
65
+ }
66
+
67
+ function escHtml (s) {
68
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
69
+ }
70
+
71
+ // ── Join ───────────────────────────────────────────────────────────────────
72
+
73
+ joinBtn.onclick = async () => {
74
+ const username = document.getElementById('username').value.trim()
75
+ const password = document.getElementById('password').value.trim()
76
+ if (!username || !password) { statusEl.textContent = 'enter username and password'; return }
77
+
78
+ joinBtn.disabled = true
79
+ statusEl.textContent = 'connecting…'
80
+
81
+ try {
82
+ const signer = new Signer(username, password, 1)
83
+ const { publicKey } = await signer.keysFor('chat')
84
+ myKey = bytesToHex(publicKey)
85
+
86
+ const registry = new RepoRegistry()
87
+
88
+ 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
+ }
96
+ })
97
+
98
+ // Open own repo
99
+ const myRepo = await registry.open(myKey)
100
+ if (!myRepo.get('name')) {
101
+ myRepo.set({ name: username, messages: [] })
102
+ }
103
+
104
+ session.interest(rootKey)
105
+ session.announce(myKey, rootKey)
106
+
107
+ // Switch to chat view
108
+ loginEl.style.display = 'none'
109
+ chatEl.style.display = 'flex'
110
+ myNameEl.textContent = `(${username})`
111
+
112
+ // Reactive rendering: re-render on any repo change
113
+ function watchRepo (keyHex, repo) {
114
+ repo.watch(`chat-render:${keyHex}`, () => renderMessages(registry))
115
+ }
116
+ 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)
122
+
123
+ // ── Send ────────────────────────────────────────────────────────────────
124
+
125
+ async function sendMessage () {
126
+ const text = inputEl.value.trim()
127
+ if (!text) return
128
+ inputEl.value = ''
129
+ const messages = myRepo.get('messages') ?? []
130
+ myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
131
+ }
132
+
133
+ sendBtn.onclick = sendMessage
134
+ inputEl.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() } }
135
+ inputEl.focus()
136
+
137
+ } catch (e) {
138
+ statusEl.textContent = `error: ${e.message}`
139
+ joinBtn.disabled = false
140
+ }
141
+ }
142
+
143
+ document.getElementById('username').onkeydown = e => { if (e.key === 'Enter') document.getElementById('password').focus() }
144
+ document.getElementById('password').onkeydown = e => { if (e.key === 'Enter') joinBtn.click() }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * proto.css — shared prototype stylesheet
3
+ *
4
+ * Deliberately rough. Use this so design bikeshedding doesn't happen.
5
+ * Replace per-app when the app is ready to grow up.
6
+ */
7
+
8
+ :root {
9
+ --ink: #1c1917;
10
+ --ink-dim: #78716c;
11
+ --paper: #fefdf8;
12
+ --rule: #e7e5e0;
13
+ --accent: #2563eb;
14
+ --warn: #ca8a04;
15
+ --flash: #fef08a;
16
+ --radius: 2px 8px 3px 7px / 7px 3px 8px 2px;
17
+ }
18
+
19
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
20
+
21
+ body {
22
+ font-family: cursive;
23
+ background: var(--paper);
24
+ color: var(--ink);
25
+ padding: 1.25rem;
26
+ line-height: 1.5;
27
+ }
28
+
29
+ /* data / code always monospace */
30
+ code, pre, .mono { font-family: monospace; }
31
+
32
+ /* hand-drawn border feel */
33
+ .box {
34
+ border: 1.5px solid var(--ink);
35
+ border-radius: var(--radius);
36
+ box-shadow: 2px 3px 0 var(--ink);
37
+ padding: 0.75rem 1rem;
38
+ }
39
+
40
+ /* status pill */
41
+ .pill {
42
+ font-family: cursive;
43
+ font-size: 0.75rem;
44
+ padding: 0.15rem 0.6rem;
45
+ border: 1.5px solid currentColor;
46
+ border-radius: 999px;
47
+ }
48
+ .pill.ok { color: #16a34a; }
49
+ .pill.err { color: #dc2626; }
50
+
51
+ /* flash animation — add .flash class, it auto-expires */
52
+ @keyframes proto-flash {
53
+ 0% { background-color: var(--flash); }
54
+ 100% { background-color: transparent; }
55
+ }
56
+ .flash { animation: proto-flash 0.7s ease-out; }
57
+
58
+ /* divider */
59
+ hr { border: none; border-top: 1.5px dashed var(--rule); margin: 0.75rem 0; }
60
+
61
+ /* muted / helper text */
62
+ .dim { color: var(--ink-dim); font-size: 0.8rem; }
63
+
64
+ /* inline code snippet */
65
+ .snippet {
66
+ font-family: monospace;
67
+ font-size: 0.85rem;
68
+ background: var(--rule);
69
+ border-radius: 3px;
70
+ padding: 0.1em 0.35em;
71
+ }
@@ -0,0 +1,109 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>streamo</title>
7
+ <link rel="stylesheet" href="/apps/styles/proto.css">
8
+ <style>
9
+ body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
10
+
11
+ .wordmark { font-size: 2.4rem; letter-spacing: -0.02em; margin-bottom: 0.15rem; }
12
+ .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 2rem; }
13
+
14
+ .ideas {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 0.6rem;
18
+ margin-bottom: 2.5rem;
19
+ }
20
+ .idea {
21
+ display: flex;
22
+ gap: 0.75rem;
23
+ align-items: baseline;
24
+ font-size: 0.9rem;
25
+ }
26
+ .idea-glyph { font-size: 1rem; flex-shrink: 0; width: 1.4rem; text-align: center; }
27
+ .idea-text { color: var(--ink-dim); }
28
+ .idea-text strong { color: var(--ink); }
29
+
30
+ hr { margin: 2rem 0; }
31
+
32
+ .apps-heading {
33
+ font-size: 0.7rem;
34
+ text-transform: uppercase;
35
+ letter-spacing: 0.1em;
36
+ color: var(--ink-dim);
37
+ margin-bottom: 1rem;
38
+ }
39
+
40
+ .app-grid {
41
+ display: grid;
42
+ grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
43
+ gap: 0.75rem;
44
+ }
45
+
46
+ .app-card {
47
+ display: block;
48
+ text-decoration: none;
49
+ color: var(--ink);
50
+ border: 1.5px solid var(--ink);
51
+ border-radius: var(--radius);
52
+ box-shadow: 2px 3px 0 var(--ink);
53
+ padding: 0.9rem 1rem;
54
+ transition: transform 0.08s, box-shadow 0.08s;
55
+ }
56
+ .app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
57
+ .app-card:active { transform: translate(1px, 2px); box-shadow: none; }
58
+
59
+ .app-name { font-size: 1rem; margin-bottom: 0.2rem; }
60
+ .app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
61
+
62
+ .footer {
63
+ margin-top: 3rem;
64
+ font-size: 0.75rem;
65
+ color: var(--ink-dim);
66
+ }
67
+ .footer a { color: var(--ink-dim); }
68
+ </style>
69
+ </head>
70
+ <body>
71
+
72
+ <div class="wordmark">streamo</div>
73
+ <p class="tagline">every device is an equal author</p>
74
+
75
+ <div class="ideas">
76
+ <div class="idea">
77
+ <span class="idea-glyph">⬡</span>
78
+ <span class="idea-text"><strong>content-addressed</strong> — data is identified by what it is, not where it lives</span>
79
+ </div>
80
+ <div class="idea">
81
+ <span class="idea-glyph">✍</span>
82
+ <span class="idea-text"><strong>signed</strong> — every write is authenticated with a secp256k1 keypair derived from your identity</span>
83
+ </div>
84
+ <div class="idea">
85
+ <span class="idea-glyph">↔</span>
86
+ <span class="idea-text"><strong>p2p sync</strong> — repos replicate over WebSocket without a central authority; the server is just another peer</span>
87
+ </div>
88
+ <div class="idea">
89
+ <span class="idea-glyph">∞</span>
90
+ <span class="idea-text"><strong>append-only</strong> — history is never rewritten; every commit is permanent and verifiable</span>
91
+ </div>
92
+ </div>
93
+
94
+ <hr>
95
+
96
+ <p class="apps-heading">apps</p>
97
+ <div class="app-grid">
98
+ <a class="app-card" href="/apps/chat/">
99
+ <div class="app-name">chat</div>
100
+ <div class="app-desc">p2p messaging — each participant owns their message stream</div>
101
+ </a>
102
+ </div>
103
+
104
+ <p class="footer">
105
+ <a href="https://github.com/dtudury/streamo">github</a>
106
+ </p>
107
+
108
+ </body>
109
+ </html>
@@ -0,0 +1,212 @@
1
+ import { ContentMap } from './ContentMap.js'
2
+
3
+ /**
4
+ * Append-only, content-addressable byte store.
5
+ *
6
+ * Each appended Uint8Array gets an address equal to the index of its last byte.
7
+ * Duplicate content is rejected: the same bytes always live at the same address.
8
+ *
9
+ * The store exposes ReadableStream / WritableStream for network sync — the wire
10
+ * format is a sequence of length-prefixed chunks (4-byte little-endian length
11
+ * followed by the chunk bytes).
12
+ */
13
+ export class Addressifier {
14
+ /** @type {Array.<{uint8Array: Uint8Array, offset: number}>} */
15
+ #chunks = []
16
+ #contentMap = new ContentMap()
17
+
18
+ #resolveNext
19
+ #nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
20
+
21
+ get byteLength () {
22
+ if (!this.#chunks.length) return 0
23
+ const last = this.#chunks[this.#chunks.length - 1]
24
+ return last.offset + last.uint8Array.length
25
+ }
26
+
27
+ /**
28
+ * Returns the Uint8Array whose last byte is at `address`.
29
+ * For negative addresses, the caller (CodecRegistry) must override.
30
+ * @param {number} address
31
+ * @returns {Uint8Array}
32
+ */
33
+ resolve (address) {
34
+ return this.#chunkAt(address).uint8Array
35
+ }
36
+
37
+ /**
38
+ * Look up the address of a previously appended chunk.
39
+ * @param {Uint8Array} code
40
+ * @returns {number|undefined}
41
+ */
42
+ addressOf (code) {
43
+ return this.#contentMap.get(code)
44
+ }
45
+
46
+ /**
47
+ * Append a new chunk to the store. Returns its address.
48
+ * Throws if the chunk is empty or already present.
49
+ * @param {Uint8Array} code
50
+ * @returns {number}
51
+ */
52
+ append (code) {
53
+ if (!code.length) throw new Error('chunk must not be empty')
54
+ if (this.#contentMap.get(code) !== undefined) throw new Error('chunk already exists')
55
+ this.#chunks.push({ uint8Array: code, offset: this.byteLength })
56
+ const address = this.byteLength - 1
57
+ this.#contentMap.set(code, address)
58
+ const prev = this.#resolveNext
59
+ this.#nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
60
+ prev(code)
61
+ return address
62
+ }
63
+
64
+ /**
65
+ * Clear all stored chunks and reset the store to empty.
66
+ * Any readers waiting on future chunks will never resolve after this call;
67
+ * use only when no live readers exist (e.g. before an archiveSync write loop).
68
+ */
69
+ _reset () {
70
+ this.#chunks = []
71
+ this.#contentMap = new ContentMap()
72
+ this.#nextChunk = new Promise(resolve => { this.#resolveNext = resolve })
73
+ }
74
+
75
+ /**
76
+ * Clone this store up to (and including) `address`.
77
+ * @param {number} address
78
+ * @returns {Addressifier}
79
+ */
80
+ clone (address) {
81
+ return this._applyClone(new Addressifier(), address)
82
+ }
83
+
84
+ /**
85
+ * Copy internal store state (chunks + content map) into `target` up to
86
+ * `address`. Called by subclass clone() methods so they can pass a
87
+ * subclass instance as `target`.
88
+ * @param {Addressifier} target
89
+ * @param {number} address
90
+ * @returns {Addressifier}
91
+ */
92
+ _applyClone (target, address) {
93
+ const idx = this.#indexAt(address, false)
94
+ target.#chunks = this.#chunks.slice(0, idx + 1)
95
+ target.#contentMap = this.#contentMap.clone(address)
96
+ return target
97
+ }
98
+
99
+ /**
100
+ * Extract a byte range as a single Uint8Array.
101
+ * @param {number} [start=0]
102
+ * @param {number} [end=this.byteLength]
103
+ * @returns {Uint8Array}
104
+ */
105
+ slice (start = 0, end = this.byteLength) {
106
+ const parts = []
107
+ for (const { uint8Array, offset } of this.#chunks) {
108
+ const chunkEnd = offset + uint8Array.length
109
+ if (chunkEnd <= start || offset >= end) continue
110
+ parts.push(uint8Array.slice(Math.max(0, start - offset), Math.min(uint8Array.length, end - offset)))
111
+ }
112
+ if (parts.length === 1) return parts[0]
113
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
114
+ let pos = 0
115
+ for (const p of parts) { out.set(p, pos); pos += p.length }
116
+ return out
117
+ }
118
+
119
+ /**
120
+ * Return a byte at a specific index (not address — just a raw byte position).
121
+ * @param {number} byteIndex
122
+ * @returns {number|undefined}
123
+ */
124
+ byteAt (byteIndex) {
125
+ const chunk = this.#chunkAt(byteIndex, false)
126
+ if (!chunk) return undefined
127
+ return chunk.uint8Array[byteIndex - chunk.offset]
128
+ }
129
+
130
+ /**
131
+ * ReadableStream that emits all chunks, then waits for new ones.
132
+ * Wire format: 4-byte LE length prefix followed by chunk bytes.
133
+ * @returns {ReadableStream}
134
+ */
135
+ makeReadableStream () {
136
+ const self = this
137
+ let index = 0
138
+ return new ReadableStream({
139
+ async start (controller) {
140
+ while (true) {
141
+ while (index < self.#chunks.length) {
142
+ const { uint8Array } = self.#chunks[index++]
143
+ const len = new Uint8Array(new Uint32Array([uint8Array.length]).buffer)
144
+ const frame = new Uint8Array(len.length + uint8Array.length)
145
+ frame.set(len)
146
+ frame.set(uint8Array, len.length)
147
+ controller.enqueue(frame)
148
+ }
149
+ await self.#nextChunk
150
+ }
151
+ }
152
+ })
153
+ }
154
+
155
+ /**
156
+ * WritableStream that accepts the wire format and calls append() for each chunk.
157
+ *
158
+ * Resilient by design: duplicate chunks are silently skipped (they are already
159
+ * stored at the same address). Frames with implausible lengths are rejected so
160
+ * a single corrupt byte cannot stall the stream indefinitely.
161
+ *
162
+ * @param {number} [maxFrameSize=64*1024*1024] reject frames larger than this
163
+ * @returns {WritableStream}
164
+ */
165
+ makeWritableStream (maxFrameSize = 64 * 1024 * 1024) {
166
+ const self = this
167
+ let buf = new Uint8Array(0)
168
+ return new WritableStream({
169
+ write (incoming) {
170
+ const next = new Uint8Array(buf.length + incoming.length)
171
+ next.set(buf); next.set(incoming, buf.length)
172
+ buf = next
173
+ while (buf.length >= 4) {
174
+ const len = new Uint32Array(buf.slice(0, 4).buffer)[0]
175
+ if (len === 0) throw new Error('malformed frame: zero-length chunk')
176
+ if (len > maxFrameSize) throw new Error(`malformed frame: length ${len} exceeds ${maxFrameSize}`)
177
+ if (buf.length < 4 + len) break
178
+ const code = buf.slice(4, 4 + len)
179
+ if (self.addressOf(code) === undefined) self.append(code)
180
+ buf = buf.slice(4 + len)
181
+ }
182
+ }
183
+ })
184
+ }
185
+
186
+ #indexAt (byteIndex, strict = true) {
187
+ const chunks = this.#chunks
188
+ if (!chunks.length) return -1
189
+ let lo = 0
190
+ let hi = chunks.length - 1
191
+ while (lo <= hi) {
192
+ const mid = (lo + hi) >>> 1
193
+ const { offset, uint8Array } = chunks[mid]
194
+ const end = offset + uint8Array.length - 1
195
+ if (strict) {
196
+ if (byteIndex === end) return mid
197
+ if (byteIndex < offset) hi = mid - 1
198
+ else lo = mid + 1
199
+ } else {
200
+ if (byteIndex >= offset && byteIndex < offset + uint8Array.length) return mid
201
+ if (byteIndex < offset) hi = mid - 1
202
+ else lo = mid + 1
203
+ }
204
+ }
205
+ return -1
206
+ }
207
+
208
+ #chunkAt (byteIndex, strict = true) {
209
+ const idx = this.#indexAt(byteIndex, strict)
210
+ return idx >= 0 ? this.#chunks[idx] : undefined
211
+ }
212
+ }