@dtudury/streamo 2.0.0 → 3.0.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.
package/README.md CHANGED
@@ -177,17 +177,27 @@ For hot-reloading, `componentKey(prefix, address)` and `defineComponent(name, fn
177
177
  | `s3Sync` | replicate chunks to S3-compatible object storage |
178
178
  | `stateFileSync` | write repo state as JSON on every change |
179
179
 
180
- ## chat example
180
+ ## the all-in-one demo
181
+
182
+ The chat server is also the website server. Run it once and you get the
183
+ homepage, chat app, **and** the repo explorer all on the same origin:
181
184
 
182
185
  ```bash
183
- # start the server its public key becomes the room key
186
+ # start the all-in-one demo server
184
187
  STREAMO_NAME=my-chat STREAMO_USERNAME=relay STREAMO_PASSWORD=secret \
185
188
  node public/apps/chat/server.js
186
189
 
187
- # join from the browser
190
+ # homepage with app cards
191
+ open http://localhost:8080/
192
+
193
+ # chat
188
194
  open http://localhost:8080/apps/chat/
189
195
 
190
- # join from the terminal
196
+ # repo explorer leave it open in another tab to watch commits roll in
197
+ # as you chat
198
+ open http://localhost:8080/apps/explorer/
199
+
200
+ # join chat from the terminal
191
201
  node public/streamo/chat-cli.js alice secret localhost 8080
192
202
  ```
193
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "peer-to-peer sync where your data and identity belong to you, not the server",
5
5
  "keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
6
6
  "repository": "git@github.com:dtudury/streamo.git",
@@ -0,0 +1,118 @@
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 explorer</title>
7
+ <link rel="stylesheet" href="/apps/styles/proto.css">
8
+ <style>
9
+ body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
10
+
11
+ .header { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.25rem; }
12
+ .wordmark { font-size: 1.6rem; letter-spacing: -0.02em; }
13
+ .crumbs { font-size: 0.85rem; color: var(--ink-dim); }
14
+ .back { cursor: pointer; color: var(--ink-dim); font-size: 0.85rem; display: inline-block; margin-bottom: 1rem; }
15
+ .back:hover { color: var(--ink); }
16
+
17
+ h2 { font-size: 1.05rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
18
+ h2 .dim { font-weight: 400; font-size: 0.9rem; }
19
+
20
+ .row {
21
+ display: grid;
22
+ grid-template-columns: 1fr 12rem 14rem;
23
+ gap: 0.75rem;
24
+ align-items: baseline;
25
+ padding: 0.55rem 0.75rem;
26
+ border: 1.5px solid transparent;
27
+ border-radius: var(--radius);
28
+ cursor: pointer;
29
+ }
30
+ .row:hover { border-color: var(--ink); background: rgba(254, 240, 138, 0.4); }
31
+ .row + .row { border-top-color: var(--rule); }
32
+ .row:hover + .row { border-top-color: transparent; }
33
+
34
+ /* signature rows show 4 columns: kind, range, hex, addr */
35
+ .row.signature { grid-template-columns: 4rem 1fr 1fr 6rem; }
36
+ .row.commit { grid-template-columns: 4rem 1fr 12rem 6rem; }
37
+
38
+ .row .mono { font-size: 0.85rem; }
39
+ .row .when { font-size: 0.78rem; color: var(--ink-dim); }
40
+ .row .msg { font-size: 0.85rem; }
41
+ .row .kind {
42
+ font-size: 0.7rem;
43
+ text-transform: uppercase;
44
+ letter-spacing: 0.08em;
45
+ color: var(--ink-dim);
46
+ border: 1px solid var(--rule);
47
+ border-radius: 999px;
48
+ padding: 0.05rem 0.5rem;
49
+ text-align: center;
50
+ align-self: center;
51
+ }
52
+ .row.commit .kind { color: var(--accent); border-color: var(--accent); }
53
+ .row.signature .kind { color: var(--warn); border-color: var(--warn); }
54
+
55
+ .empty { color: var(--ink-dim); padding: 0.5rem 0.75rem; font-size: 0.9rem; }
56
+
57
+ /* key/value table for the at-view */
58
+ .kv { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin: 0.75rem 0; }
59
+ .kv td { padding: 0.4rem 0.6rem; vertical-align: top; }
60
+ .kv tr + tr td { border-top: 1px dashed var(--rule); }
61
+ .kv td:first-child {
62
+ color: var(--ink-dim);
63
+ width: 8rem;
64
+ font-size: 0.78rem;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.06em;
67
+ }
68
+
69
+ /* clickable variant — whole row is the click target */
70
+ .kv.clickable tr { cursor: pointer; }
71
+ .kv.clickable tr:hover td { background: rgba(254, 240, 138, 0.4); }
72
+ .kv.clickable td:last-child { color: var(--accent); text-align: right; }
73
+
74
+ .addr-link {
75
+ font-family: monospace;
76
+ font-size: 0.85rem;
77
+ color: var(--accent);
78
+ cursor: pointer;
79
+ text-decoration: underline dotted;
80
+ }
81
+ .addr-link:hover { background: var(--flash); text-decoration-style: solid; }
82
+
83
+ .paths { list-style: none; padding: 0; }
84
+ .paths li { padding: 0.2rem 0.5rem; font-size: 0.85rem; }
85
+ .paths li + li { border-top: 1px dashed var(--rule); }
86
+
87
+ h3 { font-size: 0.9rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
88
+ h3 .dim { font-weight: 400; font-size: 0.85rem; }
89
+
90
+ .conn { font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 1.5rem; }
91
+ .conn.ok { color: #16a34a; }
92
+ .conn.err { color: #dc2626; }
93
+
94
+ .keyfull { font-family: monospace; font-size: 0.78rem; color: var(--ink-dim); word-break: break-all; }
95
+
96
+ pre.value {
97
+ font-family: monospace;
98
+ font-size: 0.8rem;
99
+ background: var(--rule);
100
+ border-radius: var(--radius);
101
+ padding: 1rem;
102
+ overflow-x: auto;
103
+ white-space: pre-wrap;
104
+ word-break: break-word;
105
+ }
106
+ </style>
107
+ </head>
108
+ <body>
109
+ <div class="header">
110
+ <div class="wordmark">streamo</div>
111
+ <div class="crumbs">explorer</div>
112
+ </div>
113
+ <div id="conn" class="conn">connecting…</div>
114
+ <div id="app"></div>
115
+
116
+ <script type="module" src="./main.js"></script>
117
+ </body>
118
+ </html>
@@ -0,0 +1,434 @@
1
+ // streamo explorer — read-only registry / repo / address browser.
2
+ //
3
+ // Three views, navigated by URL hash:
4
+ // #/ — registry list
5
+ // #/repo/<keyHex> — chunks (commits + signatures) in a repo
6
+ // #/repo/<keyHex>/at/<address> — the value at any address
7
+ //
8
+ // State lives in plain JS variables; reactivity is bridged from each Repo's
9
+ // internal Recaller into the app-level Recaller via the `signal` pattern
10
+ // (see chat/main.js for the same approach).
11
+
12
+ import { h } from '../../streamo/h.js'
13
+ import { mount } from '../../streamo/mount.js'
14
+ import { Recaller } from '../../streamo/utils/Recaller.js'
15
+ import { RepoRegistry } from '../../streamo/RepoRegistry.js'
16
+ import { registrySync } from '../../streamo/registrySync.js'
17
+ import { changedPaths } from '../../streamo/Streamo.js'
18
+
19
+ // ── Connect ───────────────────────────────────────────────────────────────
20
+
21
+ const registry = new RepoRegistry()
22
+ const port = +location.port || 80
23
+ const connEl = document.getElementById('conn')
24
+
25
+ try {
26
+ await registrySync(registry, location.hostname, port)
27
+ connEl.textContent = `connected · ${location.hostname}:${port}`
28
+ connEl.classList.add('ok')
29
+ } catch (e) {
30
+ connEl.textContent = `connection failed: ${e.message}`
31
+ connEl.classList.add('err')
32
+ throw e
33
+ }
34
+
35
+ // ── App-level reactivity ──────────────────────────────────────────────────
36
+
37
+ const recaller = new Recaller('explorer')
38
+ const signal = {}
39
+ const dep = () => recaller.reportKeyAccess(signal, 'data')
40
+
41
+ const schedule = typeof requestAnimationFrame !== 'undefined'
42
+ ? fn => requestAnimationFrame(fn)
43
+ : fn => queueMicrotask(fn)
44
+ let scheduled = false
45
+ function fire () {
46
+ if (scheduled) return
47
+ scheduled = true
48
+ schedule(() => { scheduled = false; recaller.reportKeyMutation(signal, 'data') })
49
+ }
50
+
51
+ const watched = new Set()
52
+ function watchRepo (key, repo) {
53
+ if (watched.has(key)) return
54
+ watched.add(key)
55
+ repo.watch(`explorer:${key}`, () => {
56
+ repo.byteLength
57
+ fire()
58
+ })
59
+ }
60
+ for (const [k, r] of registry) watchRepo(k, r)
61
+ registry.onOpen((k, r) => { watchRepo(k, r); fire() })
62
+
63
+ // ── Hash routing ──────────────────────────────────────────────────────────
64
+
65
+ function viewFromHash () {
66
+ const m = (location.hash || '#/').match(/^#\/repo\/([0-9a-f]+)(?:\/at\/(\d+))?\/?$/i)
67
+ if (!m) return { kind: 'registry' }
68
+ if (m[2] != null) return { kind: 'at', keyHex: m[1], address: +m[2] }
69
+ return { kind: 'repo', keyHex: m[1] }
70
+ }
71
+
72
+ function hashFromView (v) {
73
+ switch (v.kind) {
74
+ case 'repo': return `#/repo/${v.keyHex}`
75
+ case 'at': return `#/repo/${v.keyHex}/at/${v.address}`
76
+ default: return '#/'
77
+ }
78
+ }
79
+
80
+ let view = viewFromHash()
81
+ function go (next) {
82
+ view = next
83
+ const target = hashFromView(next)
84
+ if (location.hash !== target) location.hash = target
85
+ fire()
86
+ }
87
+ window.addEventListener('hashchange', () => {
88
+ const next = viewFromHash()
89
+ if (next.kind === view.kind && next.keyHex === view.keyHex && next.address === view.address) return
90
+ view = next
91
+ fire()
92
+ })
93
+
94
+ // ── Helpers ───────────────────────────────────────────────────────────────
95
+
96
+ const truncKey = k => k.slice(0, 12) + '…'
97
+ const truncHex = (b, n = 16) => Array.from(b.subarray(0, n)).map(x => x.toString(16).padStart(2, '0')).join('') + (b.length > n ? '…' : '')
98
+ const fmtDate = d => d ? d.toLocaleString() : ''
99
+
100
+ function isCommitShape (v) {
101
+ return v && typeof v === 'object' && !Array.isArray(v) &&
102
+ typeof v.message === 'string' && v.date instanceof Date &&
103
+ typeof v.dataAddress === 'number'
104
+ }
105
+
106
+ function safeJSON (value) {
107
+ return JSON.stringify(value, (_, v) => {
108
+ if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
109
+ if (v instanceof Date) return v.toISOString()
110
+ return v
111
+ }, 2)
112
+ }
113
+
114
+ // Walk every chunk newest-to-oldest. Each chunk's address is the index of
115
+ // its last byte; the next chunk back ends at addr - chunk.length.
116
+ function * repoEntries (repo) {
117
+ const len = repo.byteLength
118
+ if (len <= 0) return
119
+ let addr = len - 1
120
+ while (addr >= 0) {
121
+ const code = repo.resolve(addr)
122
+ if (!code || !code.length) return
123
+ const type = repo.footerToCodec[code.at(-1)]?.type
124
+ if (type === 'SIGNATURE') {
125
+ let sig
126
+ try { sig = repo.decode(addr) } catch { sig = null }
127
+ if (sig) {
128
+ // Per Streamo.sign / .verify, signed range is [sig.address, sigAddr - chunkLen + 1),
129
+ // i.e. last covered byte index = sigAddr - chunkLen. The sig chunk itself
130
+ // spans [sigAddr - chunkLen + 1, sigAddr], so coverage runs right up to
131
+ // (but does not include) the sig chunk's first byte.
132
+ yield {
133
+ kind: 'signature',
134
+ address: addr,
135
+ signedFrom: sig.address,
136
+ signedTo: addr - code.length,
137
+ chunkStart: addr - code.length + 1,
138
+ hex: truncHex(sig.compactRawBytes, 12)
139
+ }
140
+ }
141
+ } else if (type === 'OBJECT') {
142
+ let value
143
+ try { value = repo.decode(addr) } catch { value = null }
144
+ if (isCommitShape(value)) {
145
+ yield { kind: 'commit', address: addr, message: value.message, date: value.date, dataAddress: value.dataAddress, parent: value.parent }
146
+ }
147
+ }
148
+ addr -= code.length
149
+ }
150
+ }
151
+
152
+ // Decode the value at an address but treat object/array as REFS (children
153
+ // are addresses, not decoded recursively). For primitives, returns the
154
+ // decoded value directly.
155
+ function valueAndChildren (repo, address) {
156
+ const code = repo.resolve(address)
157
+ const codecType = repo.footerToCodec[code.at(-1)]?.type
158
+ const refs = repo.asRefs(address)
159
+ // refs is either an object/array of addresses or just the address itself for primitives
160
+ return { codecType, refs, decoded: repo.decode(address) }
161
+ }
162
+
163
+ // ── Views ─────────────────────────────────────────────────────────────────
164
+
165
+ function RegistryView () {
166
+ return h`
167
+ <h2>repos <span class="dim">${() => { dep(); return `(${[...registry].length})` }}</span></h2>
168
+ ${() => {
169
+ dep()
170
+ const rows = []
171
+ for (const [keyHex, repo] of registry) {
172
+ const last = repo.lastCommit
173
+ rows.push(h`
174
+ <div class="row" data-key=${keyHex} data-action="open-repo">
175
+ <span class="mono">${truncKey(keyHex)}</span>
176
+ <span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
177
+ <span class="msg dim">${last?.message || ''}</span>
178
+ </div>
179
+ `)
180
+ }
181
+ return rows.length ? rows : h`<div class="empty">waiting for repos…</div>`
182
+ }}
183
+ `
184
+ }
185
+
186
+ function RepoView ({ keyHex }) {
187
+ return h`
188
+ <a class="back" data-action="back-registry">← all repos</a>
189
+ <div class="keyfull">${keyHex}</div>
190
+ ${() => {
191
+ dep()
192
+ const repo = registry.get(keyHex)
193
+ if (!repo) return h`<div class="empty">opening…</div>`
194
+ const entries = [...repoEntries(repo)]
195
+ if (!entries.length) {
196
+ return h`
197
+ <h2>chunks <span class="dim">(0)</span></h2>
198
+ <div class="empty">no signed commits yet</div>
199
+ `
200
+ }
201
+ const commitCount = entries.filter(e => e.kind === 'commit').length
202
+ const sigCount = entries.length - commitCount
203
+ return h`
204
+ <h2>chunks <span class="dim">(${commitCount} commit${commitCount === 1 ? '' : 's'} · ${sigCount} sig${sigCount === 1 ? '' : 's'})</span></h2>
205
+ ${entries.map(e => e.kind === 'commit'
206
+ ? h`
207
+ <div class="row commit" data-key=${`c${e.address}`} data-action="open-at"
208
+ data-keyhex=${keyHex} data-addr=${e.address}>
209
+ <span class="kind">commit</span>
210
+ <span class="msg">${e.message || h`<span class="dim">(no message)</span>`}</span>
211
+ <span class="when">${fmtDate(e.date)}</span>
212
+ <span class="mono dim">@${e.address}</span>
213
+ </div>`
214
+ : h`
215
+ <div class="row signature" data-key=${`s${e.address}`} data-action="open-at"
216
+ data-keyhex=${keyHex} data-addr=${e.address}>
217
+ <span class="kind">sig</span>
218
+ <span class="mono dim">covers @${e.signedFrom}…@${e.signedTo}</span>
219
+ <span class="mono dim">${e.hex}</span>
220
+ <span class="mono dim">@${e.address}</span>
221
+ </div>`
222
+ )}
223
+ `
224
+ }}
225
+ `
226
+ }
227
+
228
+ function AtView ({ keyHex, address }) {
229
+ return h`
230
+ <a class="back" data-action="back-repo" data-keyhex=${keyHex}>← chunks</a>
231
+ <div class="keyfull">${truncKey(keyHex)} @ ${address}</div>
232
+ ${() => {
233
+ dep()
234
+ const repo = registry.get(keyHex)
235
+ if (!repo) return h`<div class="empty">opening…</div>`
236
+ if (address >= repo.byteLength) return h`<div class="empty">loading…</div>`
237
+
238
+ let info
239
+ try { info = valueAndChildren(repo, address) }
240
+ catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
241
+
242
+ const { codecType, refs, decoded } = info
243
+ const isCommit = isCommitShape(decoded)
244
+
245
+ // For commits, render the rich commit panel + changed paths.
246
+ if (isCommit) {
247
+ const parentDataAddr = decoded.parent !== undefined
248
+ ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
249
+ : undefined
250
+ const changes = parentDataAddr !== undefined
251
+ ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
252
+ : null
253
+ return h`
254
+ <div class="dim">codec: ${codecType} · this is a commit</div>
255
+ <table class="kv">
256
+ <tbody>
257
+ <tr><td>message</td><td>${decoded.message || h`<span class="dim">(empty)</span>`}</td></tr>
258
+ <tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
259
+ <tr>
260
+ <td>dataAddress</td>
261
+ <td><a class="addr-link" data-action="open-at"
262
+ data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
263
+ >@${decoded.dataAddress}</a></td>
264
+ </tr>
265
+ <tr>
266
+ <td>parent</td>
267
+ <td>${decoded.parent === undefined
268
+ ? h`<span class="dim">(none — first commit)</span>`
269
+ : h`<a class="addr-link" data-action="open-at"
270
+ data-keyhex=${keyHex} data-addr=${decoded.parent}
271
+ >@${decoded.parent}</a>`}</td>
272
+ </tr>
273
+ </tbody>
274
+ </table>
275
+ ${changes
276
+ ? h`
277
+ <h3>changed paths <span class="dim">(${changes.length})</span></h3>
278
+ ${changes.length
279
+ ? h`<ul class="paths">${changes.map(p => h`<li class="mono">${p.length === 0 ? '/' : p.join('.')}</li>`)}</ul>`
280
+ : h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
281
+ `
282
+ : null}
283
+ <h3>rehydrated</h3>
284
+ <pre class="value">${safeJSON(decoded)}</pre>
285
+ ${rawChunkSection(repo, address)}
286
+ `
287
+ }
288
+
289
+ // Signature: dedicated layout.
290
+ if (codecType === 'SIGNATURE') {
291
+ const chunk = repo.resolve(address)
292
+ const chunkLen = chunk.length
293
+ const signedTo = address - chunkLen // last byte covered (inclusive)
294
+ const sigChunkStart = address - chunkLen + 1 // first byte of the sig chunk
295
+ return h`
296
+ <div class="dim">codec: ${codecType}</div>
297
+ <table class="kv">
298
+ <tbody>
299
+ <tr>
300
+ <td>covers</td>
301
+ <td><a class="addr-link" data-action="open-at"
302
+ data-keyhex=${keyHex} data-addr=${decoded.address}
303
+ >@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
304
+ </tr>
305
+ <tr>
306
+ <td>sig chunk</td>
307
+ <td class="mono">@${sigChunkStart}…@${address} (${chunkLen} bytes)</td>
308
+ </tr>
309
+ <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
310
+ </tbody>
311
+ </table>
312
+ ${rawChunkSection(repo, address)}
313
+ `
314
+ }
315
+
316
+ // Object/array: clickable children with their addresses.
317
+ if (refs && typeof refs === 'object') {
318
+ const isArray = Array.isArray(refs)
319
+ const entries = isArray
320
+ ? refs.map((addr, i) => [String(i), addr])
321
+ : Object.entries(refs)
322
+ if (entries.length === 0) {
323
+ return h`
324
+ <div class="dim">codec: ${codecType}</div>
325
+ <div class="empty">${isArray ? '[]' : '{}'}</div>
326
+ ${rawChunkSection(repo, address)}
327
+ `
328
+ }
329
+ return h`
330
+ <div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
331
+ <table class="kv clickable">
332
+ <tbody>
333
+ ${entries.map(([k, childAddr]) => {
334
+ let preview = ''
335
+ try {
336
+ const v = repo.decode(childAddr)
337
+ preview = previewValue(v)
338
+ } catch { preview = '(error)' }
339
+ return h`
340
+ <tr data-key=${k} data-action="open-at"
341
+ data-keyhex=${keyHex} data-addr=${childAddr}>
342
+ <td class="mono">${k}</td>
343
+ <td>${preview}</td>
344
+ <td class="mono dim">@${childAddr}</td>
345
+ </tr>
346
+ `
347
+ })}
348
+ </tbody>
349
+ </table>
350
+ <h3>rehydrated</h3>
351
+ <pre class="value">${safeJSON(decoded)}</pre>
352
+ ${rawChunkSection(repo, address)}
353
+ `
354
+ }
355
+
356
+ // Primitive: just show it.
357
+ return h`
358
+ <div class="dim">codec: ${codecType}</div>
359
+ <pre class="value">${safeJSON(decoded)}</pre>
360
+ ${rawChunkSection(repo, address)}
361
+ `
362
+ }}
363
+ `
364
+ }
365
+
366
+ // Hex dump of the chunk at this address — the actual bytes that live in the
367
+ // streamo for this value. For commits we also include this so you can see
368
+ // the literal commit-record bytes.
369
+ function rawChunkSection (repo, address) {
370
+ let bytes
371
+ try { bytes = repo.resolve(address) }
372
+ catch { return null }
373
+ if (!bytes || !bytes.length) return null
374
+ return h`
375
+ <h3>chunk bytes <span class="dim">(${bytes.length} bytes ending @${address})</span></h3>
376
+ <pre class="value mono">${hexDump(bytes)}</pre>
377
+ `
378
+ }
379
+
380
+ function previewValue (v) {
381
+ if (v == null) return String(v)
382
+ if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
383
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
384
+ if (v instanceof Date) return v.toISOString()
385
+ if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
386
+ if (Array.isArray(v)) return `[…] (${v.length})`
387
+ if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
388
+ return String(v)
389
+ }
390
+
391
+ function safeGet (f) { try { return f() } catch { return undefined } }
392
+
393
+ // Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
394
+ // chunk doesn't blow up the page.
395
+ function hexDump (bytes, maxLen = 256) {
396
+ const lines = []
397
+ const len = Math.min(bytes.length, maxLen)
398
+ for (let i = 0; i < len; i += 16) {
399
+ const offset = i.toString(16).padStart(4, '0')
400
+ const slice = bytes.subarray(i, Math.min(i + 16, len))
401
+ const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0')).join(' ')
402
+ const ascii = Array.from(slice).map(b => (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : '·').join('')
403
+ lines.push(`${offset} ${hex.padEnd(48)} ${ascii}`)
404
+ }
405
+ if (bytes.length > maxLen) lines.push(`… (${bytes.length - maxLen} more bytes)`)
406
+ return lines.join('\n')
407
+ }
408
+
409
+ // ── Mount ─────────────────────────────────────────────────────────────────
410
+
411
+ const appEl = document.getElementById('app')
412
+
413
+ mount(h`${() => {
414
+ dep()
415
+ switch (view.kind) {
416
+ case 'registry': return RegistryView()
417
+ case 'repo': return RepoView({ keyHex: view.keyHex })
418
+ case 'at': return AtView({ keyHex: view.keyHex, address: view.address })
419
+ default: return h`<div class="empty">?</div>`
420
+ }
421
+ }}`, appEl, recaller)
422
+
423
+ // ── Click delegation ──────────────────────────────────────────────────────
424
+
425
+ appEl.addEventListener('click', e => {
426
+ const el = e.target.closest('[data-action]')
427
+ if (!el) return
428
+ switch (el.dataset.action) {
429
+ case 'open-repo': return go({ kind: 'repo', keyHex: el.dataset.key })
430
+ case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
431
+ case 'back-registry': return go({ kind: 'registry' })
432
+ case 'back-repo': return go({ kind: 'repo', keyHex: el.dataset.keyhex })
433
+ }
434
+ })
package/public/index.html CHANGED
@@ -99,7 +99,14 @@
99
99
  <div class="app-name">chat</div>
100
100
  <div class="app-desc">p2p messaging — the server is a relay, not a gatekeeper</div>
101
101
  </a>
102
+ <a class="app-card" href="/apps/explorer/">
103
+ <div class="app-name">explorer</div>
104
+ <div class="app-desc">browse repos, commit history, and value at any commit</div>
105
+ </a>
102
106
  </div>
107
+ <p class="dim" style="margin-top: 1rem; font-size: 0.78rem;">
108
+ open both side by side — watch commits roll in as you chat
109
+ </p>
103
110
 
104
111
  <p class="footer">
105
112
  <a href="https://github.com/dtudury/streamo">github</a>
@@ -33,6 +33,13 @@ export function * changedPaths (streamo, addrA, addrB, path = []) {
33
33
  const objA = isPlain(refsA)
34
34
  const objB = isPlain(refsB)
35
35
  if (objA || objB) {
36
+ // Array length is not in Object.keys but watchers may read arr.length
37
+ // and register a dep on [...path, 'length']. Fire that path explicitly
38
+ // so length-watchers see length changes; without this, they only fire
39
+ // when an index they happen to read changes.
40
+ if (Array.isArray(refsA) && Array.isArray(refsB) && refsA.length !== refsB.length) {
41
+ yield [...path, 'length']
42
+ }
36
43
  const keys = new Set([...Object.keys(refsA ?? {}), ...Object.keys(refsB ?? {})])
37
44
  for (const key of keys) {
38
45
  const a = objA ? refsA[key] : undefined
@@ -321,7 +328,11 @@ export class Streamo extends CodecRegistry {
321
328
  */
322
329
  async sign (signer, streamoName) {
323
330
  const before = super.byteLength
324
- const bytes = this.slice(this.#signedLength, before - 1)
331
+ // Slice end is exclusive, so [signedLength, before) is the full byte range
332
+ // appended since the last signature. (Earlier code used `before - 1` here
333
+ // and dropped the final byte — the footer of the last pre-sig chunk —
334
+ // from the signature's coverage. Matching change in verify below.)
335
+ const bytes = this.slice(this.#signedLength, before)
325
336
  const compactRawBytes = await signer.sign(streamoName, bytes)
326
337
  if (super.byteLength !== before) throw new Error('streamo was modified while signing')
327
338
  const sig = new Signature(this.#signedLength, compactRawBytes)
@@ -339,7 +350,11 @@ export class Streamo extends CodecRegistry {
339
350
  async verify (sig, publicKey) {
340
351
  const sigCode = this.encode(sig)
341
352
  const sigAddress = this.addressOf(sigCode)
342
- const bytes = this.slice(sig.address, sigAddress - sigCode.length)
353
+ // The sig chunk's first byte is at sigAddress - sigCode.length + 1, so
354
+ // the byte just before the sig chunk is at sigAddress - sigCode.length.
355
+ // Slice end is exclusive, so [sig.address, sigAddress - sigCode.length + 1)
356
+ // covers all bytes up to and including that byte — matching sign() above.
357
+ const bytes = this.slice(sig.address, sigAddress - sigCode.length + 1)
343
358
  return verifySignature(publicKey, bytes, sig.compactRawBytes)
344
359
  }
345
360
 
@@ -376,10 +391,13 @@ export class Streamo extends CodecRegistry {
376
391
 
377
392
  // If this is a SIGNATURE chunk, verify it covers the bytes since its
378
393
  // stated start address before we accept it into the store.
394
+ // self.byteLength here is the length BEFORE this sig chunk is
395
+ // appended, so [sig.address, self.byteLength) is the full pre-sig
396
+ // range — matching sign() / verify() above.
379
397
  const codec = self.footerToCodec[code.at(-1)]
380
398
  if (codec?.type === 'SIGNATURE') {
381
399
  const sig = self.decode(code)
382
- const bytes = self.slice(sig.address, self.byteLength - 1)
400
+ const bytes = self.slice(sig.address, self.byteLength)
383
401
  const valid = await verifySignature(publicKey, bytes, sig.compactRawBytes)
384
402
  if (!valid) throw new Error('signature verification failed')
385
403
  }
@@ -30,6 +30,12 @@ function adaptWebSocket (ws) {
30
30
  // to the correct repository without any per-connection state table.
31
31
  const KEY_BYTES = 33
32
32
 
33
+ // Keep-alive: send a {type:'ping'} JSON frame periodically so PaaS hosts that
34
+ // idle-close WebSockets don't drop us. Browsers don't expose WS ping/pong
35
+ // frames, so we use a JSON message — the receiver silently ignores unknown
36
+ // types, but the frame itself counts as activity.
37
+ const KEEPALIVE_INTERVAL_MS = 20000
38
+
33
39
  /**
34
40
  * @typedef {Object} RegistrySyncOptions
35
41
  *
@@ -191,6 +197,11 @@ export function handleRegistryPeer (ws, registry, options = {}, label = 'registr
191
197
  // Announce what we already have
192
198
  sendCatalog()
193
199
 
200
+ // Keep-alive heartbeat — both sides ping; receivers ignore unknown types.
201
+ const keepalive = setInterval(() => {
202
+ if (ws.readyState === ws.OPEN) sendJson({ type: 'ping' })
203
+ }, KEEPALIVE_INTERVAL_MS)
204
+
194
205
  ws.on('message', async data => {
195
206
  // Normalize to Uint8Array — works for Node Buffer, ArrayBuffer, Uint8Array, string
196
207
  const buf = typeof data === 'string' ? new TextEncoder().encode(data)
@@ -245,6 +256,7 @@ export function handleRegistryPeer (ws, registry, options = {}, label = 'registr
245
256
  })
246
257
 
247
258
  function cleanup () {
259
+ clearInterval(keepalive)
248
260
  registry.offOpen(onNewRepo)
249
261
  for (const reader of readers.values()) reader.cancel().catch(() => {})
250
262
  for (const [keyHex, fn] of followFns) {
@@ -32,9 +32,10 @@ export class Recaller {
32
32
  }
33
33
 
34
34
  unwatch (f) {
35
- // Also drop f from the pending queue: a mutation may have queued it before
36
- // unwatch was called, and the next #flush() would resurrect it via watch()
37
- // — re-establishing all its dependencies and undoing the unwatch.
35
+ // Drop f from the pending queue (catches the case where unwatch happens
36
+ // before #flush() starts) and clear its deps/name. The complementary fix
37
+ // in #flush() checking #names presence per item handles the harder
38
+ // case where unwatch happens MID-flush, after the batch was snapshotted.
38
39
  this.#pending.delete(f)
39
40
  this.#disassociate(f)
40
41
  }
@@ -69,11 +70,14 @@ export class Recaller {
69
70
  }
70
71
  const batch = [...this.#pending]
71
72
  this.#pending = new Set()
72
- batch.forEach(f => {
73
- const name = this.#names.get(f) ?? f.name ?? '(unnamed)'
74
- this.#disassociate(f)
75
- this.watch(name, f)
76
- })
73
+ for (const f of batch) {
74
+ // Skip watchers unwatched during this flush — e.g. when processing one
75
+ // watcher tears down DOM that contained another watcher's slot anchor.
76
+ // #names is the source of truth for "is this watcher still registered."
77
+ if (!this.#names.has(f)) continue
78
+ const name = this.#names.get(f)
79
+ this.watch(name, f) // watch() handles its own #disassociate
80
+ }
77
81
  loops++
78
82
  }
79
83
  this.#flushing = false