@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/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' ||
@@ -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))
@@ -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.decode(addrA, true) : undefined
31
- const refsB = addrB !== undefined ? streamo.decode(addrB, true) : undefined
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 asRefs to avoid decoding untouched subtrees,
150
- // then rebuild only the changed path bottom-up, reusing sibling addresses.
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.asRefs(addr)
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.asRefs(addr), key: path[path.length - 1] })
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.asRefs(addr)
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.asRefs(addr), key: path[path.length - 1] })
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
- const bytes = this.slice(this.#signedLength, before - 1)
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
- const bytes = this.slice(sig.address, sigAddress - sigCode.length)
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 - 1)
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
- // When someone announces directly, subscribe to their repo immediately
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, set profile if first time
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
  })
@@ -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
- return [
24
- this.v[0] instanceof Duple ? this.v[0].flat() : this.v[0],
25
- this.v[1] instanceof Duple ? this.v[1].flat() : this.v[1]
26
- ].flat()
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
- return r.addressOf(code) ?? r.append(code)
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
- const proto = Object.getPrototypeOf(v)
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
- return { UNDEFINED, NULL, FALSE, TRUE, WORD, UINT8ARRAY, EMPTY_STRING, STRING, UINT7, FLOAT64, DATE, SIGNATURE, DUPLE, EMPTY_ARRAY, ARRAY, EMPTY_OBJECT, OBJECT, VARIABLE }
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
- // 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.
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
- batch.forEach(f => {
73
- const name = this.#names.get(f) ?? f.name ?? '(unnamed)'
74
- this.#disassociate(f)
75
- this.watch(name, f)
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