@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.
- package/.claude/settings.local.json +10 -0
- package/LICENSE +661 -0
- package/README.md +194 -0
- package/ROADMAP.md +111 -0
- package/bin/streamo.js +238 -0
- package/jsconfig.json +9 -0
- package/package.json +26 -0
- package/public/apps/chat/index.html +61 -0
- package/public/apps/chat/main.js +144 -0
- package/public/apps/styles/proto.css +71 -0
- package/public/index.html +109 -0
- package/public/streamo/Addressifier.js +212 -0
- package/public/streamo/CodecRegistry.js +195 -0
- package/public/streamo/ContentMap.js +79 -0
- package/public/streamo/DESIGN.md +61 -0
- package/public/streamo/Repo.js +176 -0
- package/public/streamo/Repo.test.js +82 -0
- package/public/streamo/RepoRegistry.js +91 -0
- package/public/streamo/RepoRegistry.test.js +87 -0
- package/public/streamo/Signature.js +15 -0
- package/public/streamo/Signer.js +91 -0
- package/public/streamo/Streamo.js +392 -0
- package/public/streamo/Streamo.test.js +205 -0
- package/public/streamo/archiveSync.js +62 -0
- package/public/streamo/chat-cli.js +122 -0
- package/public/streamo/chat-server.js +60 -0
- package/public/streamo/codecs.js +400 -0
- package/public/streamo/fileSync.js +238 -0
- package/public/streamo/h.js +202 -0
- package/public/streamo/h.mount.test.js +67 -0
- package/public/streamo/h.test.js +121 -0
- package/public/streamo/mount.js +248 -0
- package/public/streamo/originSync.js +60 -0
- package/public/streamo/outletSync.js +105 -0
- package/public/streamo/registrySync.js +333 -0
- package/public/streamo/registrySync.test.js +373 -0
- package/public/streamo/s3Sync.js +99 -0
- package/public/streamo/stateFileSync.js +17 -0
- package/public/streamo/sync.test.js +98 -0
- package/public/streamo/utils/NestedSet.js +41 -0
- package/public/streamo/utils/Recaller.js +77 -0
- package/public/streamo/utils/mockDOM.js +113 -0
- package/public/streamo/utils/nextTick.js +22 -0
- package/public/streamo/utils/noble-secp256k1.js +602 -0
- package/public/streamo/utils/testing.js +90 -0
- package/public/streamo/utils.js +57 -0
- package/public/streamo/webSync.js +118 -0
- package/scripts/serve.js +15 -0
- 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 & b < c > 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
|
+
}
|