@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 +14 -4
- package/package.json +1 -1
- package/public/apps/explorer/index.html +118 -0
- package/public/apps/explorer/main.js +434 -0
- package/public/index.html +7 -0
- package/public/streamo/Streamo.js +21 -3
- package/public/streamo/registrySync.js +12 -0
- package/public/streamo/utils/Recaller.js +12 -8
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
|
-
##
|
|
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
|
|
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
|
-
#
|
|
190
|
+
# homepage with app cards
|
|
191
|
+
open http://localhost:8080/
|
|
192
|
+
|
|
193
|
+
# chat
|
|
188
194
|
open http://localhost:8080/apps/chat/
|
|
189
195
|
|
|
190
|
-
#
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// —
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
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
|