@dtudury/streamo 2.0.0 → 4.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 +40 -15
- package/package.json +3 -3
- package/public/apps/chat/main.js +18 -1
- package/public/apps/explorer/index.html +303 -0
- package/public/apps/explorer/main.js +1078 -0
- package/public/index.html +7 -0
- package/public/streamo/Addressifier.js +9 -0
- package/public/streamo/CodecRegistry.js +95 -0
- package/public/streamo/Repo.js +20 -2
- package/public/streamo/Signer.js +10 -0
- package/public/streamo/Streamo.js +64 -12
- package/public/streamo/chat-cli.js +19 -3
- package/public/streamo/codecs.js +49 -9
- package/public/streamo/registrySync.js +23 -0
- package/public/streamo/utils/Recaller.js +23 -8
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>
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Addressifier — append-only content-addressable byte store.
|
|
3
|
+
*
|
|
4
|
+
* The byte-level foundation. Knows nothing about types or values; just
|
|
5
|
+
* appends Uint8Array chunks and indexes them by content. Higher layers
|
|
6
|
+
* (CodecRegistry, Streamo, Repo, registrySync) build on this.
|
|
7
|
+
*
|
|
8
|
+
* See design.md §1.
|
|
9
|
+
*/
|
|
1
10
|
import { ContentMap } from './ContentMap.js'
|
|
2
11
|
|
|
3
12
|
/**
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CodecRegistry — codec dispatcher on top of Addressifier.
|
|
3
|
+
*
|
|
4
|
+
* Resolves bytes ↔ JS values via a registered codec table; chunks identify
|
|
5
|
+
* their codec by their last byte (the footer). Public read APIs (asRefs,
|
|
6
|
+
* directReferences, decode) are mutation-impossible by construction; the
|
|
7
|
+
* write companion _asRefsForWrite is internal.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §3–4.
|
|
10
|
+
*/
|
|
1
11
|
import { Addressifier } from './Addressifier.js'
|
|
2
12
|
import { makeCodecs } from './codecs.js'
|
|
3
13
|
|
|
@@ -21,6 +31,9 @@ export class CodecRegistry extends Addressifier {
|
|
|
21
31
|
footerToCodec = []
|
|
22
32
|
|
|
23
33
|
#codecs
|
|
34
|
+
// Read-only scope depth — only mutated by #runReadOnly below, never
|
|
35
|
+
// touched directly. The codec interface exposes it as r.readOnly (boolean).
|
|
36
|
+
#readOnlyDepth = 0
|
|
24
37
|
|
|
25
38
|
constructor () {
|
|
26
39
|
super()
|
|
@@ -32,11 +45,27 @@ export class CodecRegistry extends Addressifier {
|
|
|
32
45
|
resolve: addr => self.resolve(addr),
|
|
33
46
|
addressOf: code => self.addressOf(code),
|
|
34
47
|
get byteLength () { return self.byteLength },
|
|
48
|
+
get readOnly () { return self.#readOnlyDepth > 0 },
|
|
35
49
|
footerToCodec: this.footerToCodec
|
|
36
50
|
})
|
|
37
51
|
this.#registerAll()
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Run `fn` in a read-only scope. Codec helpers see `r.readOnly === true`
|
|
56
|
+
* for the duration and avoid mutation paths (specifically getPartAddress
|
|
57
|
+
* in codecs.js returns undefined instead of materializing inline
|
|
58
|
+
* children). Used by asRefs.
|
|
59
|
+
*
|
|
60
|
+
* The depth counter handles the case where `fn` itself re-enters this
|
|
61
|
+
* method — outer scopes stay read-only until they all unwind.
|
|
62
|
+
*/
|
|
63
|
+
#runReadOnly (fn) {
|
|
64
|
+
this.#readOnlyDepth++
|
|
65
|
+
try { return fn() }
|
|
66
|
+
finally { this.#readOnlyDepth-- }
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
// Re-expose byteLength so subclasses can override it
|
|
41
70
|
get byteLength () { return super.byteLength }
|
|
42
71
|
|
|
@@ -110,6 +139,72 @@ export class CodecRegistry extends Addressifier {
|
|
|
110
139
|
* @returns {Object|Array|number}
|
|
111
140
|
*/
|
|
112
141
|
asRefs (address) {
|
|
142
|
+
const code = this.resolve(address)
|
|
143
|
+
const { type } = this.footerToCodec[code.at(-1)]
|
|
144
|
+
if (type === 'VARIABLE' ||
|
|
145
|
+
type === 'OBJECT' || type === 'EMPTY_OBJECT' ||
|
|
146
|
+
type === 'ARRAY' || type === 'EMPTY_ARRAY') {
|
|
147
|
+
// Decode in a read-only scope so codecs cannot materialize inline
|
|
148
|
+
// children. Inline addresses come back as `undefined`; callers
|
|
149
|
+
// handle that (e.g. by rendering the child without a clickable
|
|
150
|
+
// link). Mutation is unreachable from here regardless of caller —
|
|
151
|
+
// by control flow, not by caller discipline.
|
|
152
|
+
return this.#runReadOnly(() => this.decode(address, true))
|
|
153
|
+
}
|
|
154
|
+
return address
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Direct chunk-graph references — the addresses this chunk's bytes point
|
|
159
|
+
* to (NOT the user-level child values asRefs returns). Walks the codec's
|
|
160
|
+
* parts: addressed parts contribute their target address; inline parts
|
|
161
|
+
* are skipped (their bytes are embedded in this chunk, no separate
|
|
162
|
+
* address). Pure read; never mutates.
|
|
163
|
+
*
|
|
164
|
+
* What you see varies by codec:
|
|
165
|
+
* - DUPLE → up to 2 references (left, right)
|
|
166
|
+
* - OBJECT/ARRAY/VARIABLE → 1 reference (the embedded Duple-tree or
|
|
167
|
+
* wrapped value, when stored separately)
|
|
168
|
+
* - STRING/UINT8ARRAY/DATE/FLOAT64 → 1 reference (the encoded bytes,
|
|
169
|
+
* when stored separately)
|
|
170
|
+
* - WORD, UINT7, EMPTY_*, primitives → none
|
|
171
|
+
* - SIGNATURE → none (its parts are data, not chunk references)
|
|
172
|
+
*
|
|
173
|
+
* Used by the explorer's storage tab to walk the chunk graph.
|
|
174
|
+
*
|
|
175
|
+
* @param {number} address
|
|
176
|
+
* @returns {number[]}
|
|
177
|
+
*/
|
|
178
|
+
directReferences (address) {
|
|
179
|
+
const code = this.resolve(address)
|
|
180
|
+
const codec = this.footerToCodec[code.at(-1)]
|
|
181
|
+
if (!codec?.partReaders?.length) return []
|
|
182
|
+
|
|
183
|
+
const refs = []
|
|
184
|
+
const footer = code.at(-1)
|
|
185
|
+
let option = footer - codec.baseFooter
|
|
186
|
+
let end = -1
|
|
187
|
+
for (let i = codec.partReaders.length - 1; i >= 0; i--) {
|
|
188
|
+
const opts = codec.partReaders[i]
|
|
189
|
+
const reader = opts[option % opts.length]
|
|
190
|
+
option = Math.floor(option / opts.length)
|
|
191
|
+
const part = reader(code.subarray(0, end))
|
|
192
|
+
end -= part.width
|
|
193
|
+
if (part.address !== undefined) refs.unshift(part.address)
|
|
194
|
+
}
|
|
195
|
+
return refs
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Internal: like asRefs but materializes inline children when needed
|
|
200
|
+
* (write context). Used by Streamo.set / setRefs during path traversal,
|
|
201
|
+
* which DOES need real addresses to navigate composite values; and the
|
|
202
|
+
* mutation it triggers is part of the same write op anyway. Public callers
|
|
203
|
+
* should use asRefs (above), which is mutation-impossible.
|
|
204
|
+
* @param {number} address
|
|
205
|
+
* @returns {Object|Array|number}
|
|
206
|
+
*/
|
|
207
|
+
_asRefsForWrite (address) {
|
|
113
208
|
const code = this.resolve(address)
|
|
114
209
|
const { type } = this.footerToCodec[code.at(-1)]
|
|
115
210
|
if (type === 'VARIABLE' ||
|
package/public/streamo/Repo.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Repo — a Streamo whose every set() becomes a signed commit.
|
|
3
|
+
*
|
|
4
|
+
* Each commit is a record { message, date, dataAddress, parent }. The
|
|
5
|
+
* commit log is what flows over the wire during sync. attachSigner
|
|
6
|
+
* makes commits sign automatically, with concurrent commits batched
|
|
7
|
+
* into one signature.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §8.
|
|
10
|
+
*/
|
|
1
11
|
import { Recaller } from './utils/Recaller.js'
|
|
2
12
|
import { Streamo, changedPaths } from './Streamo.js'
|
|
3
13
|
|
|
@@ -23,6 +33,14 @@ export class Repo extends Streamo {
|
|
|
23
33
|
#signing = false
|
|
24
34
|
#signPending = false
|
|
25
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Default commit message attached to every commit made via set() / setRefs().
|
|
38
|
+
* Empty by default — clients opt in to set this for attribution. The chat web
|
|
39
|
+
* client sets 'web' so commits are visibly distinguishable from a CLI
|
|
40
|
+
* client's. Not enforced; explicit commit(working, msg) wins.
|
|
41
|
+
*/
|
|
42
|
+
defaultMessage = ''
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Attach a signer so every commit is automatically signed.
|
|
28
46
|
* Concurrent commits are batched: if a sign is in flight when another
|
|
@@ -109,7 +127,7 @@ export class Repo extends Streamo {
|
|
|
109
127
|
const prevDataAddress = this.lastCommit?.dataAddress
|
|
110
128
|
const working = this.checkout()
|
|
111
129
|
working.set(...args)
|
|
112
|
-
const result = this.commit(working)
|
|
130
|
+
const result = this.commit(working, this.defaultMessage)
|
|
113
131
|
const newDataAddress = this.lastCommit?.dataAddress
|
|
114
132
|
for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
|
|
115
133
|
this.recaller.reportKeyMutation(this, JSON.stringify(changed))
|
|
@@ -146,7 +164,7 @@ export class Repo extends Streamo {
|
|
|
146
164
|
const prevDataAddress = this.lastCommit?.dataAddress
|
|
147
165
|
const working = this.checkout()
|
|
148
166
|
working.setRefs(...args)
|
|
149
|
-
const result = this.commit(working)
|
|
167
|
+
const result = this.commit(working, this.defaultMessage)
|
|
150
168
|
const newDataAddress = this.lastCommit?.dataAddress
|
|
151
169
|
for (const changed of changedPaths(this, prevDataAddress, newDataAddress)) {
|
|
152
170
|
this.recaller.reportKeyMutation(this, JSON.stringify(changed))
|
package/public/streamo/Signer.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Signer — deterministic secp256k1 keypairs from credentials.
|
|
3
|
+
*
|
|
4
|
+
* PBKDF2-SHA256 (256 bits) is used twice — first to derive a hashword
|
|
5
|
+
* from password+username, then a private key per stream-name. No key
|
|
6
|
+
* files; same credentials always produce the same identity. KAT in
|
|
7
|
+
* Signer.test.js pins the byte output across runtime versions.
|
|
8
|
+
*
|
|
9
|
+
* See design.md §7.
|
|
10
|
+
*/
|
|
1
11
|
import { getPublicKey, signAsync, verify } from './utils/noble-secp256k1.js'
|
|
2
12
|
|
|
3
13
|
const cryptoSubtle = typeof crypto !== 'undefined' ? crypto.subtle : (await import('crypto')).webcrypto.subtle
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Streamo — reactive content-addressed signed byte store.
|
|
3
|
+
*
|
|
4
|
+
* Layers Recaller-driven path-level reactivity on top of CodecRegistry,
|
|
5
|
+
* plus a sign/verify API for secp256k1 attestations over byte ranges.
|
|
6
|
+
* `valueAddress` skips trailing SIGNATURE chunks so reading the latest
|
|
7
|
+
* value works whether or not it has been auto-signed yet.
|
|
8
|
+
*
|
|
9
|
+
* Exports: Streamo (the class), ConflictError, changedPaths.
|
|
10
|
+
*
|
|
11
|
+
* See design.md §5.
|
|
12
|
+
*/
|
|
1
13
|
import { Recaller } from './utils/Recaller.js'
|
|
2
14
|
import { CodecRegistry } from './CodecRegistry.js'
|
|
3
15
|
import { Signature } from './Signature.js'
|
|
@@ -23,16 +35,35 @@ export class ConflictError extends Error {
|
|
|
23
35
|
/**
|
|
24
36
|
* Yield every path where addrA and addrB differ, including the root.
|
|
25
37
|
* Compares by address so unchanged subtrees are skipped in O(1).
|
|
38
|
+
*
|
|
39
|
+
* Uses streamo.asRefs (mutation-impossible) rather than decode(_, true)
|
|
40
|
+
* so the comparison cannot append chunks. Without this, calling
|
|
41
|
+
* changedPaths during Streamo.set could materialize inline children as
|
|
42
|
+
* separate chunks AFTER the new commit, moving valueAddress past the
|
|
43
|
+
* commit and corrupting Repo.lastCommit.
|
|
44
|
+
*
|
|
45
|
+
* Tradeoff: asRefs returns `undefined` for inline children's addresses,
|
|
46
|
+
* so changedPaths can't see differences that happen entirely inside
|
|
47
|
+
* inline-only subtrees. The parent path still fires, which is enough
|
|
48
|
+
* for any watcher that doesn't read at a depth past where the structure
|
|
49
|
+
* goes inline.
|
|
26
50
|
*/
|
|
27
51
|
export function * changedPaths (streamo, addrA, addrB, path = []) {
|
|
28
52
|
if (addrA === addrB) return
|
|
29
53
|
yield path
|
|
30
|
-
const refsA = addrA !== undefined ? streamo.
|
|
31
|
-
const refsB = addrB !== undefined ? streamo.
|
|
54
|
+
const refsA = addrA !== undefined ? streamo.asRefs(addrA) : undefined
|
|
55
|
+
const refsB = addrB !== undefined ? streamo.asRefs(addrB) : undefined
|
|
32
56
|
const isPlain = v => v != null && typeof v === 'object' && (Array.isArray(v) || Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
|
|
33
57
|
const objA = isPlain(refsA)
|
|
34
58
|
const objB = isPlain(refsB)
|
|
35
59
|
if (objA || objB) {
|
|
60
|
+
// Array length is not in Object.keys but watchers may read arr.length
|
|
61
|
+
// and register a dep on [...path, 'length']. Fire that path explicitly
|
|
62
|
+
// so length-watchers see length changes; without this, they only fire
|
|
63
|
+
// when an index they happen to read changes.
|
|
64
|
+
if (Array.isArray(refsA) && Array.isArray(refsB) && refsA.length !== refsB.length) {
|
|
65
|
+
yield [...path, 'length']
|
|
66
|
+
}
|
|
36
67
|
const keys = new Set([...Object.keys(refsA ?? {}), ...Object.keys(refsB ?? {})])
|
|
37
68
|
for (const key of keys) {
|
|
38
69
|
const a = objA ? refsA[key] : undefined
|
|
@@ -79,6 +110,14 @@ export class Streamo extends CodecRegistry {
|
|
|
79
110
|
*/
|
|
80
111
|
append (code) {
|
|
81
112
|
const address = super.append(code)
|
|
113
|
+
// If the appended chunk is a SIGNATURE, advance the signed cursor — no
|
|
114
|
+
// matter whether the caller was sign(), an archive replay, or a peer
|
|
115
|
+
// stream. Otherwise a fresh load (signedLength=0) followed by a new
|
|
116
|
+
// sign() would re-sign all of history, producing a sig whose signedFrom
|
|
117
|
+
// collides with every prior sig.
|
|
118
|
+
if (this.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
|
|
119
|
+
this.#signedLength = super.byteLength - code.length
|
|
120
|
+
}
|
|
82
121
|
this.#recaller.reportKeyMutation(this, 'length')
|
|
83
122
|
return address
|
|
84
123
|
}
|
|
@@ -146,16 +185,19 @@ export class Streamo extends CodecRegistry {
|
|
|
146
185
|
}
|
|
147
186
|
super.append(this.encode(encodedValue))
|
|
148
187
|
} else {
|
|
149
|
-
// Path update: navigate via
|
|
150
|
-
// then rebuild only the changed path bottom-up, reusing sibling
|
|
188
|
+
// Path update: navigate via _asRefsForWrite to avoid decoding untouched
|
|
189
|
+
// subtrees, then rebuild only the changed path bottom-up, reusing sibling
|
|
190
|
+
// addresses. (The public asRefs is mutation-impossible; the internal
|
|
191
|
+
// _asRefsForWrite allows materializing inline children, which is
|
|
192
|
+
// appropriate here because we're inside a write op.)
|
|
151
193
|
const levels = []
|
|
152
194
|
let addr = baseAddress
|
|
153
195
|
for (let i = 0; i < path.length - 1; i++) {
|
|
154
|
-
const refs = this.
|
|
196
|
+
const refs = this._asRefsForWrite(addr)
|
|
155
197
|
levels.push({ refs, key: path[i] })
|
|
156
198
|
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
157
199
|
}
|
|
158
|
-
levels.push({ refs: this.
|
|
200
|
+
levels.push({ refs: this._asRefsForWrite(addr), key: path[path.length - 1] })
|
|
159
201
|
|
|
160
202
|
// Encode the new leaf value
|
|
161
203
|
const leafCode = this.encode(value)
|
|
@@ -217,11 +259,11 @@ export class Streamo extends CodecRegistry {
|
|
|
217
259
|
const levels = []
|
|
218
260
|
let addr = baseAddress
|
|
219
261
|
for (let i = 0; i < path.length - 1; i++) {
|
|
220
|
-
const refs = this.
|
|
262
|
+
const refs = this._asRefsForWrite(addr)
|
|
221
263
|
levels.push({ refs, key: path[i] })
|
|
222
264
|
addr = Array.isArray(refs) ? refs[+path[i]] : refs[path[i]]
|
|
223
265
|
}
|
|
224
|
-
levels.push({ refs: this.
|
|
266
|
+
levels.push({ refs: this._asRefsForWrite(addr), key: path[path.length - 1] })
|
|
225
267
|
|
|
226
268
|
for (let i = levels.length - 1; i >= 0; i--) {
|
|
227
269
|
const { refs, key } = levels[i]
|
|
@@ -321,12 +363,15 @@ export class Streamo extends CodecRegistry {
|
|
|
321
363
|
*/
|
|
322
364
|
async sign (signer, streamoName) {
|
|
323
365
|
const before = super.byteLength
|
|
324
|
-
|
|
366
|
+
// Slice end is exclusive, so [signedLength, before) is the full byte range
|
|
367
|
+
// appended since the last signature. (Earlier code used `before - 1` here
|
|
368
|
+
// and dropped the final byte — the footer of the last pre-sig chunk —
|
|
369
|
+
// from the signature's coverage. Matching change in verify below.)
|
|
370
|
+
const bytes = this.slice(this.#signedLength, before)
|
|
325
371
|
const compactRawBytes = await signer.sign(streamoName, bytes)
|
|
326
372
|
if (super.byteLength !== before) throw new Error('streamo was modified while signing')
|
|
327
373
|
const sig = new Signature(this.#signedLength, compactRawBytes)
|
|
328
374
|
this.append(this.encode(sig))
|
|
329
|
-
this.#signedLength = before
|
|
330
375
|
return sig
|
|
331
376
|
}
|
|
332
377
|
|
|
@@ -339,7 +384,11 @@ export class Streamo extends CodecRegistry {
|
|
|
339
384
|
async verify (sig, publicKey) {
|
|
340
385
|
const sigCode = this.encode(sig)
|
|
341
386
|
const sigAddress = this.addressOf(sigCode)
|
|
342
|
-
|
|
387
|
+
// The sig chunk's first byte is at sigAddress - sigCode.length + 1, so
|
|
388
|
+
// the byte just before the sig chunk is at sigAddress - sigCode.length.
|
|
389
|
+
// Slice end is exclusive, so [sig.address, sigAddress - sigCode.length + 1)
|
|
390
|
+
// covers all bytes up to and including that byte — matching sign() above.
|
|
391
|
+
const bytes = this.slice(sig.address, sigAddress - sigCode.length + 1)
|
|
343
392
|
return verifySignature(publicKey, bytes, sig.compactRawBytes)
|
|
344
393
|
}
|
|
345
394
|
|
|
@@ -376,10 +425,13 @@ export class Streamo extends CodecRegistry {
|
|
|
376
425
|
|
|
377
426
|
// If this is a SIGNATURE chunk, verify it covers the bytes since its
|
|
378
427
|
// stated start address before we accept it into the store.
|
|
428
|
+
// self.byteLength here is the length BEFORE this sig chunk is
|
|
429
|
+
// appended, so [sig.address, self.byteLength) is the full pre-sig
|
|
430
|
+
// range — matching sign() / verify() above.
|
|
379
431
|
const codec = self.footerToCodec[code.at(-1)]
|
|
380
432
|
if (codec?.type === 'SIGNATURE') {
|
|
381
433
|
const sig = self.decode(code)
|
|
382
|
-
const bytes = self.slice(sig.address, self.byteLength
|
|
434
|
+
const bytes = self.slice(sig.address, self.byteLength)
|
|
383
435
|
const valid = await verifySignature(publicKey, bytes, sig.compactRawBytes)
|
|
384
436
|
if (!valid) throw new Error('signature verification failed')
|
|
385
437
|
}
|
|
@@ -42,21 +42,35 @@ console.log(`root key: ${rootKey.slice(0, 16)}…`)
|
|
|
42
42
|
console.log('─'.repeat(40))
|
|
43
43
|
|
|
44
44
|
const registry = new RepoRegistry()
|
|
45
|
+
// Track who we've announced ourselves back to (deduped to prevent
|
|
46
|
+
// ping-pong) — see comment in chat/main.js for the discovery pattern.
|
|
47
|
+
const announcedTo = new Set()
|
|
45
48
|
const session = await registrySync(registry, host, port, {
|
|
46
49
|
filter: k => k === rootKey,
|
|
47
50
|
follow: (keyHex, repo, subscribe) => {
|
|
48
|
-
// Auto-follow all members listed in the root repo
|
|
51
|
+
// Auto-follow all members listed in the root repo (only present when
|
|
52
|
+
// the server has chat-room onAnnounce wiring).
|
|
49
53
|
for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
|
|
50
54
|
},
|
|
51
55
|
onAnnounce: (key) => {
|
|
52
|
-
//
|
|
56
|
+
// Subscribe to the announcer AND announce ourselves back so they
|
|
57
|
+
// learn we exist. Makes chat work even when the server has no
|
|
58
|
+
// member-tracking onAnnounce of its own.
|
|
53
59
|
session.subscribe(key)
|
|
60
|
+
if (!announcedTo.has(key)) {
|
|
61
|
+
announcedTo.add(key)
|
|
62
|
+
session.announce(myKey, rootKey)
|
|
63
|
+
}
|
|
54
64
|
}
|
|
55
65
|
})
|
|
56
66
|
|
|
57
|
-
// Open my own repo
|
|
67
|
+
// Open my own repo and attach our signer so commits go out signed —
|
|
68
|
+
// "every write is provably yours" only holds if we actually sign. Set
|
|
69
|
+
// profile on first run.
|
|
58
70
|
const myRepo = await registry.open(myKey)
|
|
71
|
+
myRepo.attachSigner(signer, 'chat')
|
|
59
72
|
if (!myRepo.get('name')) {
|
|
73
|
+
myRepo.defaultMessage = `joined as ${username} (cli)`
|
|
60
74
|
myRepo.set({ name: username, messages: [] })
|
|
61
75
|
}
|
|
62
76
|
|
|
@@ -110,6 +124,8 @@ rl.on('line', async line => {
|
|
|
110
124
|
const text = line.trim()
|
|
111
125
|
if (!text) { rl.prompt(); return }
|
|
112
126
|
const messages = myRepo.get('messages') ?? []
|
|
127
|
+
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
128
|
+
myRepo.defaultMessage = `"${preview}" (cli)`
|
|
113
129
|
myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
|
|
114
130
|
rl.prompt()
|
|
115
131
|
})
|
package/public/streamo/codecs.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file codecs — concrete codecs for every value type Streamo can encode.
|
|
3
|
+
*
|
|
4
|
+
* Primitives (UNDEFINED, NULL, FALSE, TRUE, UINT7, FLOAT64, DATE), bytes
|
|
5
|
+
* (WORD, UINT8ARRAY, EMPTY_UINT8ARRAY), strings, composites (OBJECT,
|
|
6
|
+
* ARRAY, EMPTY_*), the SIGNATURE chunk, and the internal balanced-tree
|
|
7
|
+
* node Duple used to scale OBJECT/ARRAY storage. Every codec is a
|
|
8
|
+
* { encode, decode, partReaders } object.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §3.
|
|
11
|
+
*/
|
|
1
12
|
import { numberToVar, varToNumber, range } from './utils.js'
|
|
2
13
|
import { Signature } from './Signature.js'
|
|
3
14
|
|
|
@@ -20,10 +31,16 @@ class Duple {
|
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
flat () {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
].
|
|
34
|
+
// Walk the Duple tree, flattening only Duple nodes — never nested user
|
|
35
|
+
// values. (Earlier code used Array.prototype.flat() which silently
|
|
36
|
+
// flattened any nested array a caller had stored, so e.g. [3, [4,5]]
|
|
37
|
+
// would round-trip as [3, 4, 5].)
|
|
38
|
+
const out = []
|
|
39
|
+
for (const child of this.v) {
|
|
40
|
+
if (child instanceof Duple) out.push(...child.flat())
|
|
41
|
+
else out.push(child)
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
flatDuples () {
|
|
@@ -158,12 +175,23 @@ export function makeCodecs (r) {
|
|
|
158
175
|
return parts
|
|
159
176
|
}
|
|
160
177
|
|
|
161
|
-
// Stable address of a single-part value (needed for DUPLE decode with asRefs)
|
|
178
|
+
// Stable address of a single-part value (needed for DUPLE decode with asRefs).
|
|
179
|
+
//
|
|
180
|
+
// For inline multi-byte parts that aren't independently stored, the only
|
|
181
|
+
// way to "give back an address" is to materialize them as a separate chunk —
|
|
182
|
+
// i.e. mutate. That's appropriate in write contexts (Streamo.set) but a
|
|
183
|
+
// bug from a read context. When the registry is in read-only mode (set by
|
|
184
|
+
// CodecRegistry.asRefs around its decode), we return undefined instead of
|
|
185
|
+
// appending. The caller (asRefs's caller, e.g. the explorer) sees an
|
|
186
|
+
// undefined child address and renders it as inline.
|
|
162
187
|
function getPartAddress (part) {
|
|
163
188
|
if (part.address !== undefined) return part.address
|
|
164
189
|
const code = part.getCode()
|
|
165
190
|
if (code.length === 1) return -(code[0] + 1) // negative address for single-byte primitives
|
|
166
|
-
|
|
191
|
+
const existing = r.addressOf(code)
|
|
192
|
+
if (existing !== undefined) return existing
|
|
193
|
+
if (r.readOnly) return undefined
|
|
194
|
+
return r.append(code)
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
// ── Codec definitions ────────────────────────────────────────────────────
|
|
@@ -353,10 +381,14 @@ export function makeCodecs (r) {
|
|
|
353
381
|
}
|
|
354
382
|
|
|
355
383
|
const EMPTY_OBJECT = {
|
|
384
|
+
// Accepts any non-array object with no own enumerable keys, including class
|
|
385
|
+
// instances. (OBJECT also doesn't check the prototype on encode, so empty
|
|
386
|
+
// class instances should be encodable too — keeping them symmetric.) Type
|
|
387
|
+
// information is lost on round-trip in both cases; the decoded value is a
|
|
388
|
+
// plain {}.
|
|
356
389
|
encode (v) {
|
|
357
390
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return
|
|
358
|
-
|
|
359
|
-
if (proto !== Object.prototype && proto !== null) return
|
|
391
|
+
if (v instanceof Uint8Array || v instanceof Date) return
|
|
360
392
|
if (Object.keys(v).length === 0) return new Uint8Array([EMPTY_OBJECT.baseFooter])
|
|
361
393
|
},
|
|
362
394
|
decode: () => ({})
|
|
@@ -396,5 +428,13 @@ export function makeCodecs (r) {
|
|
|
396
428
|
}
|
|
397
429
|
}
|
|
398
430
|
|
|
399
|
-
|
|
431
|
+
// Empty-Uint8Array codec is appended at the END of the registration list so
|
|
432
|
+
// it doesn't shift the footer values of existing codecs — chunks created
|
|
433
|
+
// before this codec was added still decode correctly.
|
|
434
|
+
const EMPTY_UINT8ARRAY = {
|
|
435
|
+
encode: v => v instanceof Uint8Array && v.length === 0 && new Uint8Array([EMPTY_UINT8ARRAY.baseFooter]),
|
|
436
|
+
decode: () => new Uint8Array(0)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { UNDEFINED, NULL, FALSE, TRUE, WORD, UINT8ARRAY, EMPTY_STRING, STRING, UINT7, FLOAT64, DATE, SIGNATURE, DUPLE, EMPTY_ARRAY, ARRAY, EMPTY_OBJECT, OBJECT, VARIABLE, EMPTY_UINT8ARRAY }
|
|
400
440
|
}
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file registrySync — bidirectional multi-repo WebSocket sync.
|
|
3
|
+
*
|
|
4
|
+
* After a "registry" handshake, both sides exchange JSON catalog/
|
|
5
|
+
* subscribe/interest/announce/ping messages and binary
|
|
6
|
+
* [33-byte-key-prefix][chunk] frames. Discovery happens via filter,
|
|
7
|
+
* follow (content-driven), or onAnnounce. 20-second keep-alive ping
|
|
8
|
+
* for PaaS hosts that idle-close.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §10.
|
|
11
|
+
*/
|
|
1
12
|
// Use native WebSocket in the browser; fall back to the `ws` package in Node.
|
|
2
13
|
const WS = globalThis.WebSocket ?? (await import('ws')).default
|
|
3
14
|
|
|
@@ -30,6 +41,12 @@ function adaptWebSocket (ws) {
|
|
|
30
41
|
// to the correct repository without any per-connection state table.
|
|
31
42
|
const KEY_BYTES = 33
|
|
32
43
|
|
|
44
|
+
// Keep-alive: send a {type:'ping'} JSON frame periodically so PaaS hosts that
|
|
45
|
+
// idle-close WebSockets don't drop us. Browsers don't expose WS ping/pong
|
|
46
|
+
// frames, so we use a JSON message — the receiver silently ignores unknown
|
|
47
|
+
// types, but the frame itself counts as activity.
|
|
48
|
+
const KEEPALIVE_INTERVAL_MS = 20000
|
|
49
|
+
|
|
33
50
|
/**
|
|
34
51
|
* @typedef {Object} RegistrySyncOptions
|
|
35
52
|
*
|
|
@@ -191,6 +208,11 @@ export function handleRegistryPeer (ws, registry, options = {}, label = 'registr
|
|
|
191
208
|
// Announce what we already have
|
|
192
209
|
sendCatalog()
|
|
193
210
|
|
|
211
|
+
// Keep-alive heartbeat — both sides ping; receivers ignore unknown types.
|
|
212
|
+
const keepalive = setInterval(() => {
|
|
213
|
+
if (ws.readyState === ws.OPEN) sendJson({ type: 'ping' })
|
|
214
|
+
}, KEEPALIVE_INTERVAL_MS)
|
|
215
|
+
|
|
194
216
|
ws.on('message', async data => {
|
|
195
217
|
// Normalize to Uint8Array — works for Node Buffer, ArrayBuffer, Uint8Array, string
|
|
196
218
|
const buf = typeof data === 'string' ? new TextEncoder().encode(data)
|
|
@@ -245,6 +267,7 @@ export function handleRegistryPeer (ws, registry, options = {}, label = 'registr
|
|
|
245
267
|
})
|
|
246
268
|
|
|
247
269
|
function cleanup () {
|
|
270
|
+
clearInterval(keepalive)
|
|
248
271
|
registry.offOpen(onNewRepo)
|
|
249
272
|
for (const reader of readers.values()) reader.cancel().catch(() => {})
|
|
250
273
|
for (const [keyHex, fn] of followFns) {
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Recaller — fine-grained reactive dependency tracker.
|
|
3
|
+
*
|
|
4
|
+
* watch(name, fn) runs fn on a tracking stack; reportKeyAccess(target,
|
|
5
|
+
* key) inside fn registers (target, key) → fn; reportKeyMutation
|
|
6
|
+
* elsewhere wakes any matching watcher on the next microtask. The flush
|
|
7
|
+
* loop is robust against unwatch happening mid-flight, which matters
|
|
8
|
+
* for slot watchers torn down during DOM reconciliation.
|
|
9
|
+
*
|
|
10
|
+
* See design.md §6.
|
|
11
|
+
*/
|
|
1
12
|
import { NestedSet } from './NestedSet.js'
|
|
2
13
|
import { nextTick } from './nextTick.js'
|
|
3
14
|
|
|
@@ -32,9 +43,10 @@ export class Recaller {
|
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
unwatch (f) {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// —
|
|
46
|
+
// Drop f from the pending queue (catches the case where unwatch happens
|
|
47
|
+
// before #flush() starts) and clear its deps/name. The complementary fix
|
|
48
|
+
// in #flush() — checking #names presence per item — handles the harder
|
|
49
|
+
// case where unwatch happens MID-flush, after the batch was snapshotted.
|
|
38
50
|
this.#pending.delete(f)
|
|
39
51
|
this.#disassociate(f)
|
|
40
52
|
}
|
|
@@ -69,11 +81,14 @@ export class Recaller {
|
|
|
69
81
|
}
|
|
70
82
|
const batch = [...this.#pending]
|
|
71
83
|
this.#pending = new Set()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
76
|
-
|
|
84
|
+
for (const f of batch) {
|
|
85
|
+
// Skip watchers unwatched during this flush — e.g. when processing one
|
|
86
|
+
// watcher tears down DOM that contained another watcher's slot anchor.
|
|
87
|
+
// #names is the source of truth for "is this watcher still registered."
|
|
88
|
+
if (!this.#names.has(f)) continue
|
|
89
|
+
const name = this.#names.get(f)
|
|
90
|
+
this.watch(name, f) // watch() handles its own #disassociate
|
|
91
|
+
}
|
|
77
92
|
loops++
|
|
78
93
|
}
|
|
79
94
|
this.#flushing = false
|