@dtudury/streamo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/LICENSE +661 -0
  3. package/README.md +194 -0
  4. package/ROADMAP.md +111 -0
  5. package/bin/streamo.js +238 -0
  6. package/jsconfig.json +9 -0
  7. package/package.json +26 -0
  8. package/public/apps/chat/index.html +61 -0
  9. package/public/apps/chat/main.js +144 -0
  10. package/public/apps/styles/proto.css +71 -0
  11. package/public/index.html +109 -0
  12. package/public/streamo/Addressifier.js +212 -0
  13. package/public/streamo/CodecRegistry.js +195 -0
  14. package/public/streamo/ContentMap.js +79 -0
  15. package/public/streamo/DESIGN.md +61 -0
  16. package/public/streamo/Repo.js +176 -0
  17. package/public/streamo/Repo.test.js +82 -0
  18. package/public/streamo/RepoRegistry.js +91 -0
  19. package/public/streamo/RepoRegistry.test.js +87 -0
  20. package/public/streamo/Signature.js +15 -0
  21. package/public/streamo/Signer.js +91 -0
  22. package/public/streamo/Streamo.js +392 -0
  23. package/public/streamo/Streamo.test.js +205 -0
  24. package/public/streamo/archiveSync.js +62 -0
  25. package/public/streamo/chat-cli.js +122 -0
  26. package/public/streamo/chat-server.js +60 -0
  27. package/public/streamo/codecs.js +400 -0
  28. package/public/streamo/fileSync.js +238 -0
  29. package/public/streamo/h.js +202 -0
  30. package/public/streamo/h.mount.test.js +67 -0
  31. package/public/streamo/h.test.js +121 -0
  32. package/public/streamo/mount.js +248 -0
  33. package/public/streamo/originSync.js +60 -0
  34. package/public/streamo/outletSync.js +105 -0
  35. package/public/streamo/registrySync.js +333 -0
  36. package/public/streamo/registrySync.test.js +373 -0
  37. package/public/streamo/s3Sync.js +99 -0
  38. package/public/streamo/stateFileSync.js +17 -0
  39. package/public/streamo/sync.test.js +98 -0
  40. package/public/streamo/utils/NestedSet.js +41 -0
  41. package/public/streamo/utils/Recaller.js +77 -0
  42. package/public/streamo/utils/mockDOM.js +113 -0
  43. package/public/streamo/utils/nextTick.js +22 -0
  44. package/public/streamo/utils/noble-secp256k1.js +602 -0
  45. package/public/streamo/utils/testing.js +90 -0
  46. package/public/streamo/utils.js +57 -0
  47. package/public/streamo/webSync.js +118 -0
  48. package/scripts/serve.js +15 -0
  49. package/smoke.test.js +132 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * h — HTML template parser
3
+ *
4
+ * Usage:
5
+ * const nodes = h`<div class=${cls}>${() => streamo.get('name')}</div>`
6
+ *
7
+ * Parses the template into a virtual tree of HElement / HText nodes.
8
+ * Interpolated values (slots) are stored as-is — functions are NOT called here.
9
+ * Pass the result to `mount` (./mount.js) to render it into the DOM.
10
+ */
11
+
12
+ const VOID = new Set([
13
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
14
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
15
+ ])
16
+
17
+ // ── Virtual tree types ───────────────────────────────────────────────────
18
+
19
+ export class HElement {
20
+ constructor (tag, attrs, children) {
21
+ this.tag = tag
22
+ this.attrs = attrs // Array.<{name, value}|any>
23
+ this.children = children // Array of slots
24
+ }
25
+ }
26
+
27
+ export class HText {
28
+ constructor (value) {
29
+ this.value = value // string
30
+ }
31
+ }
32
+
33
+ // ── Scanner ──────────────────────────────────────────────────────────────
34
+
35
+ class Scanner {
36
+ #tokens
37
+ #i = 0
38
+
39
+ constructor (strings, values) {
40
+ this.#tokens = []
41
+ for (let i = 0; i < strings.length; i++) {
42
+ for (const c of strings[i]) this.#tokens.push(c)
43
+ if (i < values.length) this.#tokens.push({ slot: values[i] })
44
+ }
45
+ }
46
+
47
+ peek (offset = 0) { return this.#tokens[this.#i + offset] }
48
+ isSlot (offset = 0) { return this.peek(offset)?.slot !== undefined }
49
+ advance () { return this.#tokens[this.#i++] }
50
+ get done () { return this.#i >= this.#tokens.length }
51
+
52
+ readIf (str) {
53
+ for (let i = 0; i < str.length; i++) {
54
+ const c = this.peek(i)
55
+ if (!c || c.slot !== undefined || c !== str[i]) return false
56
+ }
57
+ this.#i += str.length
58
+ return true
59
+ }
60
+
61
+ assertChar (re) {
62
+ const c = this.peek()
63
+ if (!c || c.slot !== undefined || !c.match(re)) {
64
+ throw new Error(`expected ${re}, got ${JSON.stringify(c)} at token ${this.#i}`)
65
+ }
66
+ this.#i++
67
+ }
68
+
69
+ skipSpace () {
70
+ while (!this.done && !this.isSlot() && this.peek().match(/\s/)) this.#i++
71
+ }
72
+
73
+ /** Read chars until regex matches or a slot is hit. */
74
+ readTo (re) {
75
+ const parts = []
76
+ while (!this.done && !this.isSlot() && !this.peek().match(re)) {
77
+ const c = this.peek()
78
+ if (c === '&') {
79
+ parts.push(this.#readEscaped())
80
+ } else {
81
+ parts.push(c)
82
+ this.#i++
83
+ }
84
+ }
85
+ return parts.join('')
86
+ }
87
+
88
+ #readEscaped () {
89
+ this.#i++ // consume '&'
90
+ for (const [seq, char] of [['amp;', '&'], ['apos;', "'"], ['gt;', '>'], ['lt;', '<'], ['quot;', '"'], ['nbsp;', '\u00a0']]) {
91
+ if (this.readIf(seq)) return char
92
+ }
93
+ throw new Error('unknown HTML escape sequence')
94
+ }
95
+
96
+ /**
97
+ * Read an attribute value that may contain interleaved slots and literal text.
98
+ * Returns a string if there are no slots, otherwise an array of string/slot parts.
99
+ */
100
+ readAttrValue (closingQuote) {
101
+ const parts = []
102
+ while (!this.done) {
103
+ if (this.isSlot()) {
104
+ parts.push(this.advance().slot)
105
+ } else if (this.peek() === closingQuote) {
106
+ break
107
+ } else if (this.peek() === '&') {
108
+ parts.push(this.#readEscaped())
109
+ } else {
110
+ let s = ''
111
+ while (!this.done && !this.isSlot() && this.peek() !== closingQuote && this.peek() !== '&') {
112
+ s += this.peek()
113
+ this.#i++
114
+ }
115
+ if (s) parts.push(s)
116
+ }
117
+ }
118
+ if (parts.every(p => typeof p === 'string')) return parts.join('')
119
+ return parts
120
+ }
121
+ }
122
+
123
+ // ── Parser ───────────────────────────────────────────────────────────────
124
+
125
+ const END_ATTRS = Symbol('end')
126
+
127
+ function parseAttr (sc) {
128
+ sc.skipSpace()
129
+ const c = sc.peek()
130
+ if (!c || c === '/' || c === '>') return END_ATTRS
131
+ // dynamic attribute object spread (e.g. ${attrs})
132
+ if (sc.isSlot()) return sc.advance().slot
133
+ const name = sc.readTo(/[\s=/>]/)
134
+ if (!name) throw new Error('attribute must have a name')
135
+ sc.skipSpace()
136
+ if (!sc.readIf('=')) return { name }
137
+ sc.skipSpace()
138
+ if (sc.isSlot()) return { name, value: sc.advance().slot }
139
+ const quote = (sc.peek() === '"' || sc.peek() === "'") ? sc.advance() : null
140
+ if (quote) {
141
+ const raw = sc.readAttrValue(quote)
142
+ sc.assertChar(new RegExp(quote))
143
+ const value = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw
144
+ return { name, value }
145
+ }
146
+ return { name, value: sc.readTo(/[\s/>]/) }
147
+ }
148
+
149
+ function parseAttrs (sc) {
150
+ const attrs = []
151
+ while (true) {
152
+ const attr = parseAttr(sc)
153
+ if (attr === END_ATTRS) return attrs
154
+ attrs.push(attr)
155
+ }
156
+ }
157
+
158
+ function parseTag (sc) {
159
+ sc.skipSpace()
160
+ if (sc.isSlot()) return sc.advance().slot
161
+ return sc.readTo(/[\s/>]/)
162
+ }
163
+
164
+ function parseElement (sc) {
165
+ if (sc.isSlot()) return sc.advance().slot
166
+ if (sc.peek() !== '<') {
167
+ const text = sc.readTo(/</)
168
+ return text ? new HText(text) : null
169
+ }
170
+ sc.assertChar(/</)
171
+ const closing = sc.readIf('/')
172
+ const tag = parseTag(sc)
173
+ const isVoid = VOID.has(tag)
174
+ const attrs = parseAttrs(sc)
175
+ const selfClose = sc.readIf('/') || isVoid
176
+ sc.assertChar(/>/)
177
+ if (closing) return { _closing: tag }
178
+ const children = selfClose ? [] : parseChildren(sc, tag)
179
+ return new HElement(tag, attrs, children)
180
+ }
181
+
182
+ function parseChildren (sc, closingTag) {
183
+ const children = []
184
+ while (!sc.done) {
185
+ const node = parseElement(sc)
186
+ if (node === null) continue
187
+ if (node?._closing) return children
188
+ children.push(node)
189
+ }
190
+ return children
191
+ }
192
+
193
+ /**
194
+ * Tagged template literal — parses the template into a virtual tree.
195
+ * @param {TemplateStringsArray} strings
196
+ * @param {...any} values
197
+ * @returns {Array} array of HElement / HText / slot values
198
+ */
199
+ export function h (strings, ...values) {
200
+ const sc = new Scanner(strings, values)
201
+ return parseChildren(sc, null)
202
+ }
@@ -0,0 +1,67 @@
1
+ import { describe } from './utils/testing.js'
2
+ import { h } from './h.js'
3
+ import { mount } from './mount.js'
4
+ import { Streamo } from './Streamo.js'
5
+
6
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node != null
7
+
8
+ if (IS_NODE) {
9
+ const { mockDocument, MockNode } = await import('./utils/mockDOM.js')
10
+ globalThis.document = mockDocument
11
+ globalThis.Node = MockNode
12
+ }
13
+
14
+ describe(import.meta.url, ({ test }) => {
15
+ test('mounts a static element with text', ({ assert }) => {
16
+ const container = document.createElement('div')
17
+ mount(h`<span>hello</span>`, container)
18
+ const span = container.childNodes[0]
19
+ assert.equal(span.textContent, 'hello')
20
+ })
21
+
22
+ test('reactive text slot updates after stream.set', async ({ assert }) => {
23
+ const stream = new Streamo()
24
+ stream.set({ greeting: 'hello' })
25
+ const container = document.createElement('div')
26
+ mount(h`<span>${() => stream.get('greeting')}</span>`, container, stream.recaller)
27
+
28
+ const span = container.childNodes[0]
29
+ assert.equal(span.textContent, 'hello', 'initial render')
30
+
31
+ stream.set('greeting', 'world')
32
+ await new Promise(resolve => setTimeout(resolve, 20))
33
+ assert.equal(span.textContent, 'world', 'updated after set')
34
+ })
35
+
36
+ test('reactive attribute updates after stream.set', async ({ assert }) => {
37
+ const stream = new Streamo()
38
+ stream.set({ cls: 'active' })
39
+ const container = document.createElement('div')
40
+ mount(h`<div class=${() => stream.get('cls')}></div>`, container, stream.recaller)
41
+
42
+ const div = container.childNodes[0]
43
+ assert.equal(div.getAttribute('class'), 'active', 'initial attribute')
44
+
45
+ stream.set('cls', 'inactive')
46
+ await new Promise(resolve => setTimeout(resolve, 20))
47
+ assert.equal(div.getAttribute('class'), 'inactive', 'updated attribute')
48
+ })
49
+
50
+ test('unrelated stream.set does not re-render slot', async ({ assert }) => {
51
+ const stream = new Streamo()
52
+ stream.set({ a: 'unchanged', b: 'watched' })
53
+ let renderCount = 0
54
+ const container = document.createElement('div')
55
+ mount(h`<span>${() => { renderCount++; return stream.get('b') }}</span>`, container, stream.recaller)
56
+
57
+ assert.equal(renderCount, 1, 'initial render')
58
+
59
+ stream.set('a', 'changed')
60
+ await new Promise(resolve => setTimeout(resolve, 20))
61
+ assert.equal(renderCount, 1, 'no re-render when unrelated key changes')
62
+
63
+ stream.set('b', 'also changed')
64
+ await new Promise(resolve => setTimeout(resolve, 20))
65
+ assert.equal(renderCount, 2, 're-renders when watched key changes')
66
+ })
67
+ })
@@ -0,0 +1,121 @@
1
+ import { describe } from './utils/testing.js'
2
+ import { h, HElement, HText } from './h.js'
3
+
4
+ describe(import.meta.url, ({ test }) => {
5
+ test('plain element', ({ assert }) => {
6
+ const [div] = h`<div></div>`
7
+ assert.ok(div instanceof HElement)
8
+ assert.equal(div.tag, 'div')
9
+ assert.deepEqual(div.attrs, [])
10
+ assert.deepEqual(div.children, [])
11
+ })
12
+
13
+ test('void element produces no children', ({ assert }) => {
14
+ const [br] = h`<br>`
15
+ assert.ok(br instanceof HElement)
16
+ assert.equal(br.tag, 'br')
17
+ assert.deepEqual(br.children, [])
18
+ })
19
+
20
+ test('static attributes', ({ assert }) => {
21
+ const [el] = h`<input type="text" placeholder='name'>`
22
+ assert.equal(el.attrs[0].name, 'type')
23
+ assert.equal(el.attrs[0].value, 'text')
24
+ assert.equal(el.attrs[1].name, 'placeholder')
25
+ assert.equal(el.attrs[1].value, 'name')
26
+ })
27
+
28
+ test('boolean attribute (no value)', ({ assert }) => {
29
+ const [el] = h`<button disabled></button>`
30
+ assert.equal(el.attrs[0].name, 'disabled')
31
+ assert.equal(el.attrs[0].value, undefined)
32
+ })
33
+
34
+ test('dynamic attribute value', ({ assert }) => {
35
+ const cls = 'active'
36
+ const [el] = h`<div class=${cls}></div>`
37
+ assert.equal(el.attrs[0].name, 'class')
38
+ assert.equal(el.attrs[0].value, 'active')
39
+ })
40
+
41
+ test('function slot as attribute value', ({ assert }) => {
42
+ const fn = () => 'red'
43
+ const [el] = h`<div style=${'color:' + fn()}></div>`
44
+ assert.equal(el.attrs[0].value, 'color:red')
45
+ })
46
+
47
+ test('text node child', ({ assert }) => {
48
+ const [el] = h`<p>hello world</p>`
49
+ assert.equal(el.children.length, 1)
50
+ assert.ok(el.children[0] instanceof HText)
51
+ assert.equal(el.children[0].value, 'hello world')
52
+ })
53
+
54
+ test('dynamic child slot', ({ assert }) => {
55
+ const val = 42
56
+ const [el] = h`<span>${val}</span>`
57
+ assert.equal(el.children[0], 42)
58
+ })
59
+
60
+ test('function child slot preserved as function', ({ assert }) => {
61
+ const fn = () => 'dynamic'
62
+ const [el] = h`<span>${fn}</span>`
63
+ assert.equal(typeof el.children[0], 'function')
64
+ assert.equal(el.children[0](), 'dynamic')
65
+ })
66
+
67
+ test('nested elements', ({ assert }) => {
68
+ const [ul] = h`<ul><li>a</li><li>b</li></ul>`
69
+ assert.equal(ul.tag, 'ul')
70
+ assert.equal(ul.children.length, 2)
71
+ assert.equal(ul.children[0].tag, 'li')
72
+ assert.equal(ul.children[1].tag, 'li')
73
+ })
74
+
75
+ test('mixed text and element children', ({ assert }) => {
76
+ const [p] = h`<p>hello <strong>world</strong></p>`
77
+ assert.ok(p.children[0] instanceof HText)
78
+ assert.equal(p.children[0].value, 'hello ')
79
+ assert.equal(p.children[1].tag, 'strong')
80
+ })
81
+
82
+ test('HTML entity decoding', ({ assert }) => {
83
+ const [el] = h`<p>a &amp; b &lt; c &gt; d</p>`
84
+ assert.equal(el.children[0].value, 'a & b < c > d')
85
+ })
86
+
87
+ test('multiple root nodes', ({ assert }) => {
88
+ const nodes = h`<li>a</li><li>b</li>`
89
+ assert.equal(nodes.length, 2)
90
+ assert.equal(nodes[0].tag, 'li')
91
+ assert.equal(nodes[1].tag, 'li')
92
+ })
93
+
94
+ test('self-closing syntax', ({ assert }) => {
95
+ const [el] = h`<MyComponent />`
96
+ assert.ok(el instanceof HElement)
97
+ assert.equal(el.tag, 'MyComponent')
98
+ assert.deepEqual(el.children, [])
99
+ })
100
+
101
+ test('mixed static/dynamic attribute value parts', ({ assert }) => {
102
+ // A plain string slot gets joined into the surrounding text
103
+ const color = 'red'
104
+ const [el1] = h`<div style="color: ${color}; font-size: 12px"></div>`
105
+ assert.equal(el1.attrs[0].value, 'color: red; font-size: 12px')
106
+
107
+ // A function slot stays as an array so mount() can wire up reactivity
108
+ const colorFn = () => 'blue'
109
+ const [el2] = h`<div style="color: ${colorFn}; font-size: 12px"></div>`
110
+ assert.ok(Array.isArray(el2.attrs[0].value))
111
+ assert.equal(el2.attrs[0].value[0], 'color: ')
112
+ assert.equal(typeof el2.attrs[0].value[1], 'function')
113
+ assert.equal(el2.attrs[0].value[2], '; font-size: 12px')
114
+ })
115
+
116
+ test('spread attribute object', ({ assert }) => {
117
+ const extra = { id: 'foo', 'data-x': '1' }
118
+ const [el] = h`<div ${extra}></div>`
119
+ assert.deepEqual(el.attrs[0], extra)
120
+ })
121
+ })
@@ -0,0 +1,248 @@
1
+ /**
2
+ * mount — reactive DOM renderer for h virtual trees
3
+ *
4
+ * Cells are functions interpolated into an h template. There are four positions
5
+ * a cell can appear, each with a consistent contract: the first argument is always
6
+ * the relevant DOM element (or container), and the return value is what gets applied.
7
+ *
8
+ * Position Syntax Called as Return value
9
+ * ─────────────────────────────────────────────────────────────────────────
10
+ * child slot ${cell} cell(container) rendered as children
11
+ * attribute value attr=${cell} cell(el) set as attribute
12
+ * event handler onclick=${cell} cell(el) assigned to el.onclick
13
+ * mixed attribute attr="prefix-${cell}" cell(el) stringified and joined
14
+ *
15
+ * All cells are wrapped in a recaller.watch() so they re-run automatically
16
+ * whenever reactive data they accessed is mutated.
17
+ *
18
+ * For event handlers (on* attributes), the cell returns the handler function.
19
+ * That handler's first argument is the DOM Event.
20
+ */
21
+
22
+ import { HElement, HText } from './h.js'
23
+
24
+ // ── Watcher cleanup registry ──────────────────────────────────────────────
25
+ //
26
+ // Each node tracks the watcher functions registered against it.
27
+ // cleanupNode() walks the subtree unwatching all of them, so removed
28
+ // nodes never accumulate stale watchers.
29
+
30
+ const nodeCleanups = new WeakMap() // Node → Set<Function>
31
+
32
+ function addCleanup (node, f) {
33
+ let set = nodeCleanups.get(node)
34
+ if (!set) { set = new Set(); nodeCleanups.set(node, set) }
35
+ set.add(f)
36
+ }
37
+
38
+ function cleanupNode (node, recaller) {
39
+ const fns = nodeCleanups.get(node)
40
+ if (fns) {
41
+ for (const f of fns) recaller.unwatch(f)
42
+ nodeCleanups.delete(node)
43
+ }
44
+ for (const child of [...node.childNodes]) cleanupNode(child, recaller)
45
+ }
46
+
47
+ /**
48
+ * Mount an array of virtual nodes (result of h``) into `container`.
49
+ * @param {Array} nodes
50
+ * @param {Element} container
51
+ * @param {import('./utils/Recaller.js').Recaller} recaller
52
+ */
53
+ export function mount (nodes, container, recaller) {
54
+ for (const node of [nodes].flat()) {
55
+ mountNode(node, container, recaller)
56
+ }
57
+ }
58
+
59
+ function mountNode (node, container, recaller) {
60
+ if (node == null) return
61
+ if (Array.isArray(node)) {
62
+ node.forEach(n => mountNode(n, container, recaller))
63
+ return
64
+ }
65
+ if (node instanceof HElement) {
66
+ const el = document.createElementNS(
67
+ node.attrs.find(a => a?.name === 'xmlns')?.value ?? 'http://www.w3.org/1999/xhtml',
68
+ node.tag
69
+ )
70
+ for (const attr of node.attrs) {
71
+ if (attr == null) continue
72
+ applyAttr(el, attr, recaller)
73
+ }
74
+ mount(node.children, el, recaller)
75
+ container.appendChild(el)
76
+ return
77
+ }
78
+ if (node instanceof HText) {
79
+ container.appendChild(document.createTextNode(node.value))
80
+ return
81
+ }
82
+ if (node instanceof Node) {
83
+ container.appendChild(node)
84
+ return
85
+ }
86
+ if (typeof node === 'function') {
87
+ mountSlot(node, container, recaller)
88
+ return
89
+ }
90
+ // primitive — string, number, etc.
91
+ container.appendChild(document.createTextNode(String(node)))
92
+ }
93
+
94
+ // ── Child slot ────────────────────────────────────────────────────────────
95
+ //
96
+ // ${cell} in content position.
97
+ // Comment anchors delimit the slot's DOM range. On re-render, existing
98
+ // elements are matched by data-key (exact) or tag (positional fallback)
99
+ // and recycled in place. Unmatched nodes are cleaned up before removal.
100
+
101
+ function mountSlot (cell, container, recaller) {
102
+ const start = document.createComment('')
103
+ const end = document.createComment('')
104
+ container.appendChild(start)
105
+ container.appendChild(end)
106
+
107
+ const watcher = () => {
108
+ const newVNodes = [cell(container)].flat(Infinity).filter(n => n != null)
109
+ reconcileSlot(start, end, newVNodes, recaller)
110
+ }
111
+ addCleanup(start, watcher)
112
+ recaller.watch(cell.name || '(h cell)', watcher)
113
+ }
114
+
115
+ function reconcileSlot (start, end, newVNodes, recaller) {
116
+ // Collect existing Element nodes between the anchors — only elements can be recycled
117
+ const existingEls = []
118
+ let node = start.nextSibling
119
+ while (node !== end) {
120
+ if (node.nodeType === Node.ELEMENT_NODE) existingEls.push(node)
121
+ node = node.nextSibling
122
+ }
123
+
124
+ // Build lookup: keyed elements by data-key, unkeyed elements pooled by tag.
125
+ // Keyed and unkeyed pools are kept separate so a keyed vnode never steals
126
+ // an unkeyed element and vice versa.
127
+ const keyedMap = new Map()
128
+ const tagPool = new Map()
129
+ for (const el of existingEls) {
130
+ const key = el.getAttribute('data-key')
131
+ if (key != null) {
132
+ keyedMap.set(key, el)
133
+ } else {
134
+ const tag = el.tagName.toLowerCase()
135
+ if (!tagPool.has(tag)) tagPool.set(tag, [])
136
+ tagPool.get(tag).push(el)
137
+ }
138
+ }
139
+
140
+ // Match each new HElement vnode to an existing element
141
+ const recycledEls = new Set()
142
+ const vnodeToEl = new Map()
143
+ for (const vnode of newVNodes) {
144
+ if (!(vnode instanceof HElement)) continue
145
+ const keyAttr = vnode.attrs.find(a => a?.name === 'data-key')
146
+ const keyVal = keyAttr?.value
147
+ // Only use static (non-reactive) key values for matching
148
+ const key = (keyVal != null && typeof keyVal !== 'function' && !Array.isArray(keyVal))
149
+ ? String(keyVal) : null
150
+
151
+ let el = null
152
+ if (key != null) {
153
+ // Keyed: only match an existing element with the same data-key
154
+ const candidate = keyedMap.get(key)
155
+ if (candidate && !recycledEls.has(candidate)) el = candidate
156
+ } else {
157
+ // Unkeyed: take the first unused same-tag element from the pool
158
+ const pool = tagPool.get(vnode.tag)
159
+ if (pool) el = pool.find(e => !recycledEls.has(e)) ?? null
160
+ }
161
+
162
+ if (el) {
163
+ recycledEls.add(el)
164
+ vnodeToEl.set(vnode, el)
165
+ }
166
+ }
167
+
168
+ // Detach recycled elements before wiping so they survive the cleanup pass
169
+ for (const el of recycledEls) el.remove()
170
+
171
+ // Clean up and remove all remaining old content
172
+ while (start.nextSibling !== end) {
173
+ const old = start.nextSibling
174
+ cleanupNode(old, recaller)
175
+ old.remove()
176
+ }
177
+
178
+ // Reinsert recycled elements (static attrs patched) and mount fresh ones, in order
179
+ for (const vnode of newVNodes) {
180
+ const recycled = vnodeToEl.get(vnode)
181
+ if (recycled) {
182
+ patchElement(recycled, vnode)
183
+ end.before(recycled)
184
+ } else {
185
+ const frag = document.createDocumentFragment()
186
+ mountNode(vnode, frag, recaller)
187
+ end.before(frag)
188
+ }
189
+ }
190
+ }
191
+
192
+ // Update static attributes on a recycled element.
193
+ // Reactive (function/array) attrs are already self-updating via their existing watchers.
194
+ function patchElement (el, vnode) {
195
+ for (const attr of vnode.attrs) {
196
+ if (attr == null) continue
197
+ if (typeof attr === 'object' && !attr.name) continue // spread — skip
198
+ if (typeof attr.value === 'function' || Array.isArray(attr.value)) continue // reactive — skip
199
+ if (attr.value !== undefined) setAttr(el, attr.name, attr.value)
200
+ else el.toggleAttribute(attr.name, true)
201
+ }
202
+ }
203
+
204
+ // ── Attribute cells ───────────────────────────────────────────────────────
205
+ //
206
+ // attr=${cell} → cell(el), return value applied via setAttr
207
+ // onclick=${cell} → cell(el), return value assigned to el.onclick
208
+ // attr="prefix-${cell}" → each fn part called with el, results joined as string
209
+
210
+ function applyAttr (el, attr, recaller) {
211
+ if (typeof attr === 'object' && !attr.name) {
212
+ // spread object: ${attrs} in attribute position
213
+ for (const [k, v] of Object.entries(attr)) applyAttr(el, { name: k, value: v }, recaller)
214
+ return
215
+ }
216
+ const { name, value } = attr
217
+ if (typeof value === 'function') {
218
+ const watcher = () => setAttr(el, name, value(el))
219
+ addCleanup(el, watcher)
220
+ recaller.watch(`attr:${name}`, watcher)
221
+ return
222
+ }
223
+ if (Array.isArray(value)) {
224
+ // mixed static/dynamic: each function part is a cell called with el
225
+ const watcher = () => {
226
+ setAttr(el, name, value.map(p => typeof p === 'function' ? p(el) : String(p ?? '')).join(''))
227
+ }
228
+ addCleanup(el, watcher)
229
+ recaller.watch(`attr:${name}`, watcher)
230
+ return
231
+ }
232
+ if (value !== undefined) setAttr(el, name, value)
233
+ else el.toggleAttribute(name, true) // boolean attribute (no value)
234
+ }
235
+
236
+ function setAttr (el, name, value) {
237
+ if (name.startsWith('on')) {
238
+ el[name] = typeof value === 'function' ? value : null
239
+ } else if (name === 'value' && 'value' in el) {
240
+ el.value = value
241
+ } else if (typeof value === 'boolean') {
242
+ el.toggleAttribute(name, value)
243
+ } else if (value == null) {
244
+ el.removeAttribute(name)
245
+ } else {
246
+ el.setAttribute(name, value)
247
+ }
248
+ }
@@ -0,0 +1,60 @@
1
+ import WebSocket from 'ws'
2
+ import { hexToBytes } from './utils.js'
3
+
4
+ /**
5
+ * Connect to a remote outlet and begin full-duplex sync for `stream`.
6
+ *
7
+ * Sends a handshake (the hex public key), then:
8
+ * local → remote: all existing chunks, then new ones as they arrive
9
+ * remote → local: chunks verified against the stream's public key
10
+ *
11
+ * @param {import('./Stream.js').Stream} stream
12
+ * @param {string} publicKeyHex hex-encoded public key identifying this stream
13
+ * @param {string} host
14
+ * @param {number} port
15
+ * @returns {Promise<WebSocket>} resolves when the connection is open and sync has started
16
+ */
17
+ export function originSync (stream, publicKeyHex, host, port) {
18
+ return new Promise((resolve, reject) => {
19
+ const ws = new WebSocket(`ws://${host}:${port}`)
20
+
21
+ ws.on('open', () => {
22
+ // Handshake: identify which stream we want to sync
23
+ ws.send(publicKeyHex)
24
+
25
+ // Local → remote: replay all chunks, then stream new ones
26
+ const reader = stream.makeReadableStream().getReader()
27
+ ;(async () => {
28
+ try {
29
+ while (true) {
30
+ const { value, done } = await reader.read()
31
+ if (done) break
32
+ if (ws.readyState === WebSocket.OPEN) ws.send(value)
33
+ else break
34
+ }
35
+ } catch {}
36
+ })()
37
+
38
+ // Remote → local: verify signature chunks before accepting
39
+ const publicKey = hexToBytes(publicKeyHex)
40
+ const writer = stream.makeVerifiedWritableStream(publicKey).getWriter()
41
+
42
+ ws.on('message', data => {
43
+ writer.write(new Uint8Array(data)).catch(e => {
44
+ console.error(`[origin] rejected chunk: ${e.message}`)
45
+ ws.close()
46
+ })
47
+ })
48
+
49
+ ws.on('close', () => reader.cancel().catch(() => {}))
50
+ ws.on('error', err => {
51
+ console.error('[origin] connection error:', err.message)
52
+ reader.cancel().catch(() => {})
53
+ })
54
+
55
+ resolve(ws)
56
+ })
57
+
58
+ ws.on('error', reject)
59
+ })
60
+ }