@dtudury/streamo 0.1.1 → 0.2.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.
@@ -1,74 +1,33 @@
1
- import { Signer } from '/streamo/Signer.js'
2
- import { RepoRegistry } from '/streamo/RepoRegistry.js'
3
- import { registrySync } from '/streamo/registrySync.js'
4
- import { bytesToHex } from '/streamo/utils.js'
1
+ import { h } from '../../streamo/h.js'
2
+ import { mount } from '../../streamo/mount.js'
3
+ import { Recaller } from '../../streamo/utils/Recaller.js'
4
+ import { Signer } from '../../streamo/Signer.js'
5
+ import { RepoRegistry } from '../../streamo/RepoRegistry.js'
6
+ import { registrySync } from '../../streamo/registrySync.js'
7
+ import { bytesToHex } from '../../streamo/utils.js'
5
8
 
6
- const loginEl = document.getElementById('login')
7
- const chatEl = document.getElementById('chat')
8
- const statusEl = document.getElementById('status')
9
- const myNameEl = document.getElementById('my-name')
10
- const msgsEl = document.getElementById('messages')
11
- const inputEl = document.getElementById('msg-input')
12
- const sendBtn = document.getElementById('send-btn')
13
- const joinBtn = document.getElementById('join-btn')
14
-
15
- const { rootKey } = await fetch('/api/chat-info').then(r => r.json())
16
-
17
- // ── Helpers ────────────────────────────────────────────────────────────────
9
+ const { primaryKeyHex: rootKey } = await fetch('/api/info').then(r => r.json())
18
10
 
19
11
  function fmt (ts) {
20
12
  return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
21
13
  }
22
14
 
23
- // ── Rendering ──────────────────────────────────────────────────────────────
24
-
25
- let myKey = null
26
-
27
- /** Flat list of { name, text, at, mine } sorted by `at` */
28
- function collectMessages (registry) {
29
- const all = []
30
- for (const [keyHex, repo] of registry) {
31
- const name = repo.get('name')
32
- const messages = repo.get('messages') ?? []
33
- for (const msg of messages) {
34
- const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
35
- const at = msg?.at ?? 0
36
- all.push({ name, text, at, mine: keyHex === myKey })
37
- }
38
- }
39
- all.sort((a, b) => a.at - b.at)
40
- return all
41
- }
42
-
43
- let rendered = 0
44
-
45
- function renderMessages (registry) {
46
- const all = collectMessages(registry)
47
- // Only append new messages (simple: clear + rebuild if out of order, else append)
48
- if (all.length < rendered) {
49
- msgsEl.innerHTML = ''
50
- rendered = 0
51
- }
52
- for (let i = rendered; i < all.length; i++) {
53
- const { name, text, at, mine } = all[i]
54
- const div = document.createElement('div')
55
- div.className = `msg ${mine ? 'mine' : 'theirs'}`
56
- div.innerHTML = `
57
- ${!mine ? `<div class="sender">${escHtml(name)}</div>` : ''}
58
- <div class="text">${escHtml(text)}</div>
15
+ function Msg ({ name, text, at, mine }) {
16
+ return h`
17
+ <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
18
+ ${!mine ? h`<div class="sender">${name}</div>` : null}
19
+ <div class="text">${text}</div>
59
20
  <div class="time">${fmt(at)}</div>
60
- `
61
- msgsEl.appendChild(div)
62
- }
63
- rendered = all.length
64
- if (all.length > rendered - 1) msgsEl.scrollTop = msgsEl.scrollHeight
21
+ </div>
22
+ `
65
23
  }
66
24
 
67
- function escHtml (s) {
68
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
69
- }
70
-
71
- // ── Join ───────────────────────────────────────────────────────────────────
25
+ const loginEl = document.getElementById('login')
26
+ const chatEl = document.getElementById('chat')
27
+ const msgsEl = document.getElementById('messages')
28
+ const inputEl = document.getElementById('msg-input')
29
+ const statusEl = document.getElementById('status')
30
+ const joinBtn = document.getElementById('join-btn')
72
31
 
73
32
  joinBtn.onclick = async () => {
74
33
  const username = document.getElementById('username').value.trim()
@@ -79,50 +38,74 @@ joinBtn.onclick = async () => {
79
38
  statusEl.textContent = 'connecting…'
80
39
 
81
40
  try {
82
- const signer = new Signer(username, password, 1)
41
+ const signer = new Signer(username, password, 1)
83
42
  const { publicKey } = await signer.keysFor('chat')
84
- myKey = bytesToHex(publicKey)
85
-
43
+ const myKey = bytesToHex(publicKey)
86
44
  const registry = new RepoRegistry()
87
45
 
88
46
  const session = await registrySync(registry, location.hostname, Number(location.port) || 80, {
89
- filter: k => k === rootKey,
90
- follow: (keyHex, repo, subscribe) => {
91
- for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
92
- },
93
- onAnnounce: (key) => {
94
- session.subscribe(key)
95
- }
47
+ filter: k => k === rootKey,
48
+ follow: (keyHex, repo, subscribe) => {
49
+ for (const memberKey of repo.get('members') ?? []) subscribe(memberKey)
50
+ },
51
+ onAnnounce: key => session.subscribe(key)
96
52
  })
97
53
 
98
- // Open own repo
99
54
  const myRepo = await registry.open(myKey)
100
- if (!myRepo.get('name')) {
101
- myRepo.set({ name: username, messages: [] })
102
- }
55
+ myRepo.attachSigner(signer, 'chat')
56
+ if (!myRepo.get('name')) myRepo.set({ name: username, messages: [] })
103
57
 
104
58
  session.interest(rootKey)
105
59
  session.announce(myKey, rootKey)
106
60
 
107
- // Switch to chat view
108
61
  loginEl.style.display = 'none'
109
- chatEl.style.display = 'flex'
110
- myNameEl.textContent = `(${username})`
62
+ chatEl.style.display = 'flex'
63
+ document.getElementById('my-name').textContent = `(${username})`
64
+
65
+ // ── Reactive message list ──────────────────────────────────────────────
66
+ //
67
+ // Each repo has its own internal Recaller, so repo.get() inside a mount
68
+ // slot won't automatically re-trigger mount's recaller. Bridge via
69
+ // reportKey*: repo.watch() calls reportKeyMutation when data changes;
70
+ // the slot calls reportKeyAccess to register the dependency.
71
+
72
+ const recaller = new Recaller('chat')
73
+ const signal = {}
74
+
75
+ function triggerRender () {
76
+ recaller.reportKeyMutation(signal, 'data')
77
+ requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
78
+ }
111
79
 
112
- // Reactive rendering: re-render on any repo change
113
80
  function watchRepo (keyHex, repo) {
114
- repo.watch(`chat-render:${keyHex}`, () => renderMessages(registry))
81
+ repo.watch(`chat:${keyHex}`, triggerRender)
115
82
  }
83
+
116
84
  for (const [k, r] of registry) watchRepo(k, r)
117
- registry.onOpen((keyHex, repo) => {
118
- watchRepo(keyHex, repo)
119
- renderMessages(registry)
120
- })
121
- renderMessages(registry)
85
+ registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
86
+
87
+ mount(h`${function messages () {
88
+ recaller.reportKeyAccess(signal, 'data')
89
+ const all = []
90
+ for (const [keyHex, repo] of registry) {
91
+ if (keyHex === rootKey) continue
92
+ const name = repo.get('name')
93
+ for (const msg of repo.get('messages') ?? []) {
94
+ const text = typeof msg === 'string' ? msg : msg?.text ?? String(msg)
95
+ const at = msg?.at ?? 0
96
+ all.push({ name, text, at, mine: keyHex === myKey })
97
+ }
98
+ }
99
+ all.sort((a, b) => a.at - b.at)
100
+ return all.map(({ name, text, at, mine }) =>
101
+ h`<${Msg} name=${name} text=${text} at=${at} mine=${mine}/>`)
102
+ }}`, msgsEl, recaller)
122
103
 
123
104
  // ── Send ────────────────────────────────────────────────────────────────
124
105
 
125
- async function sendMessage () {
106
+ const sendBtn = document.getElementById('send-btn')
107
+
108
+ function sendMessage () {
126
109
  const text = inputEl.value.trim()
127
110
  if (!text) return
128
111
  inputEl.value = ''
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { config } from 'dotenv'
4
+ import { StreamoServer } from '../../streamo/StreamoServer.js'
5
+
6
+ const envFile = process.argv.find((_, i) => process.argv[i - 1] === '--env-file')
7
+ if (envFile) config({ path: envFile })
8
+
9
+ const name = process.env.STREAMO_NAME ?? 'chat'
10
+ const username = process.env.STREAMO_USERNAME ?? 'relay'
11
+ const password = process.env.STREAMO_PASSWORD ?? ''
12
+ const port = +(process.env.STREAMO_WEB ?? 8080)
13
+ const dataDir = process.env.STREAMO_DATA_DIR ?? '.streamo'
14
+ const keyIter = +(process.env.STREAMO_KEY_ITERATIONS ?? 100000)
15
+
16
+ const server = await StreamoServer.create({ name, username, password, dataDir, keyIterations: keyIter })
17
+
18
+ console.log(`[chat] room key: ${server.publicKeyHex}`)
19
+ console.log(`[chat] serving on http://localhost:${port}/apps/chat/`)
20
+
21
+ if (!server.streamo.get('members')) {
22
+ server.streamo.set({ ...(server.streamo.get() ?? {}), members: [] })
23
+ console.log('[chat] initialized chat room')
24
+ }
25
+
26
+ await server.web(port, {
27
+ onAnnounce: (key, topic) => {
28
+ if (topic !== server.publicKeyHex) return
29
+ const members = server.streamo.get('members') ?? []
30
+ if (!members.includes(key)) {
31
+ server.streamo.set({ ...(server.streamo.get() ?? {}), members: [...members, key] })
32
+ console.log(`[chat] new member: ${key.slice(0, 12)}…`)
33
+ }
34
+ }
35
+ })
@@ -18,6 +18,42 @@ import { Streamo, changedPaths } from './Streamo.js'
18
18
  * for read-only inspection or direct use with the explicit commit() API.
19
19
  */
20
20
  export class Repo extends Streamo {
21
+ #signer = null
22
+ #signerName = null
23
+ #signing = false
24
+ #signPending = false
25
+
26
+ /**
27
+ * Attach a signer so every commit is automatically signed.
28
+ * Concurrent commits are batched: if a sign is in flight when another
29
+ * commit lands, one more sign runs after the current one finishes,
30
+ * covering all accumulated commits in a single signature.
31
+ *
32
+ * @param {import('./Signer.js').Signer} signer
33
+ * @param {string} name stream name passed to signer.keysFor()
34
+ */
35
+ attachSigner (signer, name) {
36
+ this.#signer = signer
37
+ this.#signerName = name
38
+ }
39
+
40
+ #scheduleSign () {
41
+ if (!this.#signer) return
42
+ if (this.#signing) { this.#signPending = true; return }
43
+ this.#signing = true
44
+ this.sign(this.#signer, this.#signerName)
45
+ .then(() => {
46
+ this.#signing = false
47
+ if (this.#signPending) {
48
+ this.#signPending = false
49
+ this.#scheduleSign()
50
+ }
51
+ })
52
+ .catch(() => {
53
+ this.#signing = false
54
+ if (this.byteLength > this.signedLength) this.#scheduleSign()
55
+ })
56
+ }
21
57
  /**
22
58
  * The latest commit record, or null if nothing has been committed yet.
23
59
  * Registers a reactive dependency on the commit log length.
@@ -171,6 +207,8 @@ export class Repo extends Streamo {
171
207
  const parent = parentAddr >= 0 ? parentAddr : undefined
172
208
  const dataAddress = this.copyFrom(workingStreamo, workingStreamo.byteLength - 1)
173
209
  const code = this.encode({ message, date: new Date(), dataAddress, parent })
174
- return this.append(code)
210
+ const result = this.append(code)
211
+ this.#scheduleSign()
212
+ return result
175
213
  }
176
214
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * StreamoComponent — base class for hot-reloadable custom element components
3
+ *
4
+ * Two levels of component in streamo:
5
+ *
6
+ * 1. Function components — plain functions used directly as tags in h``:
7
+ *
8
+ * function Card ({ title, children }) {
9
+ * return h`<div class="card"><h2>${title}</h2>${children}</div>`
10
+ * }
11
+ * mount(h`<${Card} title="Hello"><p>hi</p></${Card}>`, body, recaller)
12
+ *
13
+ * Attr values are passed as-is: reactive function attrs stay as functions,
14
+ * so the component can forward them straight into its own slots.
15
+ *
16
+ * 2. Custom element components (this file) — for hot-reloading via content
17
+ * addresses. Each file version gets a unique element name; stale elements
18
+ * become orphans (different tag → no recycling → cleaned up automatically).
19
+ *
20
+ * Typical pattern:
21
+ *
22
+ * // When the address of Card.js changes in the reactive store:
23
+ * const cardTag = () => {
24
+ * const addr = repo.get('components.card')
25
+ * if (!addr) return null
26
+ * return defineComponent(componentKey('s-card', addr), ({ title }) =>
27
+ * h`<div class="card"><h2>${title}</h2></div>`
28
+ * )
29
+ * }
30
+ * mount(h`${() => { const t = cardTag(); return t && h`<${t} title="Hello"/>` }}`, body, recaller)
31
+ */
32
+
33
+ import { Recaller } from './utils/Recaller.js'
34
+ import { mount, dismount } from './mount.js'
35
+
36
+ export class StreamoComponent extends HTMLElement {
37
+ #recaller
38
+ #root
39
+
40
+ connectedCallback () {
41
+ this.#recaller = new Recaller(this.localName)
42
+ this.#root = this.attachShadow({ mode: 'open' })
43
+ mount(this.render(this.#buildProps()), this.#root, this.#recaller)
44
+ }
45
+
46
+ disconnectedCallback () {
47
+ if (this.#root) {
48
+ dismount(this.#root, this.#recaller)
49
+ this.#root = null
50
+ }
51
+ }
52
+
53
+ // Override in subclasses or via defineComponent.
54
+ render (props) { return [] }
55
+
56
+ #buildProps () {
57
+ const props = {}
58
+ for (const { name, value } of this.attributes) props[name] = value
59
+ props.children = [...this.childNodes]
60
+ return props
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Generate a valid custom element name from a prefix and a content address.
66
+ * The address provides uniqueness — a new address means a new element name,
67
+ * so stale elements are naturally orphaned without any explicit cleanup.
68
+ *
69
+ * @param {string} prefix e.g. 's-card' (must contain a hyphen)
70
+ * @param {string} address hex content address from the reactive store
71
+ * @returns {string} e.g. 's-card-a1b2c3d4e5f6g7h8'
72
+ */
73
+ export function componentKey (prefix, address) {
74
+ return `${prefix}-${String(address).slice(0, 16).toLowerCase()}`
75
+ }
76
+
77
+ /**
78
+ * Register a render function as a custom element. Safe to call multiple times
79
+ * with the same name — subsequent calls are no-ops.
80
+ *
81
+ * @param {string} name valid custom element name (must contain a hyphen)
82
+ * @param {Function} renderFn (props) => virtual nodes
83
+ * @returns {string} the name, for use directly as a tag
84
+ */
85
+ export function defineComponent (name, renderFn) {
86
+ if (!customElements.get(name)) {
87
+ customElements.define(name, class extends StreamoComponent {
88
+ render (props) { return renderFn(props) }
89
+ })
90
+ }
91
+ return name
92
+ }
@@ -0,0 +1,71 @@
1
+ import { Repo } from './Repo.js'
2
+ import { RepoRegistry } from './RepoRegistry.js'
3
+ import { Signer } from './Signer.js'
4
+ import { archiveSync } from './archiveSync.js'
5
+ import { fileSync } from './fileSync.js'
6
+ import { originSync } from './originSync.js'
7
+ import { outletSync } from './outletSync.js'
8
+ import { s3Sync } from './s3Sync.js'
9
+ import { stateFileSync } from './stateFileSync.js'
10
+ import { bytesToHex } from './utils.js'
11
+ import { webSync } from './webSync.js'
12
+
13
+ export class StreamoServer {
14
+ #dataDir
15
+ #keyIterations
16
+
17
+ name
18
+ username
19
+ publicKeyHex
20
+ signer
21
+ streamo
22
+ registry
23
+
24
+ constructor (fields) {
25
+ Object.assign(this, fields)
26
+ }
27
+
28
+ static async create ({ name, username, password, dataDir = '.streamo', keyIterations = 100000 }) {
29
+ const signer = new Signer(username, password, keyIterations)
30
+ const { publicKey } = await signer.keysFor(name)
31
+ const publicKeyHex = bytesToHex(publicKey)
32
+
33
+ const registry = new RepoRegistry(async key => {
34
+ const repo = new Repo()
35
+ await archiveSync(repo, dataDir, key)
36
+ return repo
37
+ })
38
+ const streamo = await registry.open(publicKeyHex)
39
+ streamo.attachSigner(signer, name)
40
+
41
+ const server = new StreamoServer({ name, username, publicKeyHex, signer, streamo, registry })
42
+ server.#dataDir = dataDir
43
+ server.#keyIterations = keyIterations
44
+ return server
45
+ }
46
+
47
+ async web (port, peerOptions = {}) {
48
+ return webSync(this.registry, this.publicKeyHex, port, this.name, this.#keyIterations, peerOptions)
49
+ }
50
+
51
+ outlet (port) {
52
+ return outletSync(this.registry, port)
53
+ }
54
+
55
+ async connect (hostPort) {
56
+ const [host, port] = hostPort.split(':')
57
+ return originSync(this.streamo, this.publicKeyHex, host, +port)
58
+ }
59
+
60
+ async files (folder = '.') {
61
+ return fileSync(this.streamo, folder, this.#dataDir)
62
+ }
63
+
64
+ async s3 ({ bucket, endpoint, region, accessKeyId, secretAccessKey }) {
65
+ return s3Sync(this.streamo, this.publicKeyHex, { bucket, endpoint, region, accessKeyId, secretAccessKey })
66
+ }
67
+
68
+ stateFile (path) {
69
+ return stateFileSync(this.streamo, path)
70
+ }
71
+ }
@@ -25,11 +25,12 @@ const signer = new Signer(username, password, 1)
25
25
  const { publicKey } = await signer.keysFor('chat')
26
26
  const myKey = bytesToHex(publicKey)
27
27
 
28
- // Fetch root key from server
28
+ // Fetch root key from server (/api/info is the canonical endpoint)
29
29
  let rootKey
30
30
  try {
31
- const res = await fetch(`http://${host}:${port}/api/chat-info`)
32
- ;({ rootKey } = await res.json())
31
+ const res = await fetch(`http://${host}:${port}/api/info`)
32
+ const info = await res.json()
33
+ rootKey = info.primaryKeyHex ?? info.rootKey
33
34
  } catch (e) {
34
35
  console.error(`could not reach server at http://${host}:${port}: ${e.message}`)
35
36
  process.exit(1)
@@ -21,6 +21,9 @@
21
21
 
22
22
  import { HElement, HText } from './h.js'
23
23
 
24
+ const HTML_NS = 'http://www.w3.org/1999/xhtml'
25
+ const SVG_NS = 'http://www.w3.org/2000/svg'
26
+
24
27
  // ── Watcher cleanup registry ──────────────────────────────────────────────
25
28
  //
26
29
  // Each node tracks the watcher functions registered against it.
@@ -44,34 +47,48 @@ function cleanupNode (node, recaller) {
44
47
  for (const child of [...node.childNodes]) cleanupNode(child, recaller)
45
48
  }
46
49
 
50
+ // Call when removing a mounted root (e.g. in disconnectedCallback of a custom element).
51
+ export function dismount (root, recaller) {
52
+ cleanupNode(root, recaller)
53
+ }
54
+
47
55
  /**
48
56
  * Mount an array of virtual nodes (result of h``) into `container`.
49
- * @param {Array} nodes
57
+ * @param {Array} nodes
50
58
  * @param {Element} container
51
59
  * @param {import('./utils/Recaller.js').Recaller} recaller
60
+ * @param {string} [ns] XML namespace inherited from parent (defaults to XHTML)
52
61
  */
53
- export function mount (nodes, container, recaller) {
62
+ export function mount (nodes, container, recaller, ns = HTML_NS) {
54
63
  for (const node of [nodes].flat()) {
55
- mountNode(node, container, recaller)
64
+ mountNode(node, container, recaller, ns)
56
65
  }
57
66
  }
58
67
 
59
- function mountNode (node, container, recaller) {
68
+ function mountNode (node, container, recaller, ns = HTML_NS) {
60
69
  if (node == null) return
61
70
  if (Array.isArray(node)) {
62
- node.forEach(n => mountNode(n, container, recaller))
71
+ node.forEach(n => mountNode(n, container, recaller, ns))
63
72
  return
64
73
  }
65
74
  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
- )
75
+ if (typeof node.tag === 'function') {
76
+ mountNode(node.tag(buildProps(node)), container, recaller, ns)
77
+ return
78
+ }
79
+ // Determine this element's namespace:
80
+ // xmlns attr > svg tag > foreignObject resets to HTML > inherit from parent
81
+ const nsAttr = node.attrs.find(a => a?.name === 'xmlns')?.value
82
+ const elemNs = nsAttr
83
+ ?? (node.tag === 'svg' ? SVG_NS
84
+ : node.tag === 'foreignObject' ? HTML_NS
85
+ : ns)
86
+ const el = document.createElementNS(elemNs, node.tag)
70
87
  for (const attr of node.attrs) {
71
88
  if (attr == null) continue
72
89
  applyAttr(el, attr, recaller)
73
90
  }
74
- mount(node.children, el, recaller)
91
+ mount(node.children, el, recaller, elemNs)
75
92
  container.appendChild(el)
76
93
  return
77
94
  }
@@ -84,7 +101,7 @@ function mountNode (node, container, recaller) {
84
101
  return
85
102
  }
86
103
  if (typeof node === 'function') {
87
- mountSlot(node, container, recaller)
104
+ mountSlot(node, container, recaller, ns)
88
105
  return
89
106
  }
90
107
  // primitive — string, number, etc.
@@ -98,7 +115,7 @@ function mountNode (node, container, recaller) {
98
115
  // elements are matched by data-key (exact) or tag (positional fallback)
99
116
  // and recycled in place. Unmatched nodes are cleaned up before removal.
100
117
 
101
- function mountSlot (cell, container, recaller) {
118
+ function mountSlot (cell, container, recaller, ns = HTML_NS) {
102
119
  const start = document.createComment('')
103
120
  const end = document.createComment('')
104
121
  container.appendChild(start)
@@ -106,13 +123,13 @@ function mountSlot (cell, container, recaller) {
106
123
 
107
124
  const watcher = () => {
108
125
  const newVNodes = [cell(container)].flat(Infinity).filter(n => n != null)
109
- reconcileSlot(start, end, newVNodes, recaller)
126
+ reconcileSlot(start, end, newVNodes, recaller, ns)
110
127
  }
111
128
  addCleanup(start, watcher)
112
129
  recaller.watch(cell.name || '(h cell)', watcher)
113
130
  }
114
131
 
115
- function reconcileSlot (start, end, newVNodes, recaller) {
132
+ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
116
133
  // Collect existing Element nodes between the anchors — only elements can be recycled
117
134
  const existingEls = []
118
135
  let node = start.nextSibling
@@ -183,7 +200,7 @@ function reconcileSlot (start, end, newVNodes, recaller) {
183
200
  end.before(recycled)
184
201
  } else {
185
202
  const frag = document.createDocumentFragment()
186
- mountNode(vnode, frag, recaller)
203
+ mountNode(vnode, frag, recaller, ns)
187
204
  end.before(frag)
188
205
  }
189
206
  }
@@ -201,11 +218,30 @@ function patchElement (el, vnode) {
201
218
  }
202
219
  }
203
220
 
221
+ // ── Function components ───────────────────────────────────────────────────
222
+ //
223
+ // When an HElement's tag is a function, call it with a props object instead
224
+ // of creating a DOM element. Attr values are passed as-is — reactive function
225
+ // attrs stay as functions so the component can forward them into its own slots.
226
+
227
+ function buildProps (node) {
228
+ const props = {}
229
+ for (const attr of node.attrs) {
230
+ if (attr == null) continue
231
+ if (typeof attr === 'object' && attr.name) props[attr.name] = attr.value
232
+ else if (typeof attr === 'object') Object.assign(props, attr) // spread
233
+ }
234
+ props.children = node.children
235
+ return props
236
+ }
237
+
204
238
  // ── Attribute cells ───────────────────────────────────────────────────────
205
239
  //
206
240
  // attr=${cell} → cell(el), return value applied via setAttr
207
241
  // onclick=${cell} → cell(el), return value assigned to el.onclick
208
242
  // attr="prefix-${cell}" → each fn part called with el, results joined as string
243
+ // class=${[...]} → falsy items filtered, truthy items joined with space
244
+ // class=${{k:bool}} → keys with truthy values joined with space
209
245
 
210
246
  function applyAttr (el, attr, recaller) {
211
247
  if (typeof attr === 'object' && !attr.name) {
@@ -221,12 +257,17 @@ function applyAttr (el, attr, recaller) {
221
257
  return
222
258
  }
223
259
  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(''))
260
+ if (value.some(p => typeof p === 'function')) {
261
+ // Mixed static/dynamic attr from template interpolation — evaluate and concatenate
262
+ const watcher = () => {
263
+ setAttr(el, name, value.map(p => typeof p === 'function' ? p(el) : String(p ?? '')).join(''))
264
+ }
265
+ addCleanup(el, watcher)
266
+ recaller.watch(`attr:${name}`, watcher)
267
+ } else {
268
+ // Static array value (e.g. class list) — delegate to setAttr for normalization
269
+ setAttr(el, name, value)
227
270
  }
228
- addCleanup(el, watcher)
229
- recaller.watch(`attr:${name}`, watcher)
230
271
  return
231
272
  }
232
273
  if (value !== undefined) setAttr(el, name, value)
@@ -234,6 +275,14 @@ function applyAttr (el, attr, recaller) {
234
275
  }
235
276
 
236
277
  function setAttr (el, name, value) {
278
+ // Normalize class arrays and objects into a space-separated string
279
+ if (name === 'class') {
280
+ if (Array.isArray(value)) {
281
+ value = value.filter(Boolean).join(' ')
282
+ } else if (value !== null && value !== undefined && typeof value === 'object') {
283
+ value = Object.entries(value).filter(([, v]) => v).map(([k]) => k).join(' ')
284
+ }
285
+ }
237
286
  if (name.startsWith('on')) {
238
287
  el[name] = typeof value === 'function' ? value : null
239
288
  } else if (name === 'value' && 'value' in el) {
@@ -25,7 +25,7 @@ const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..')
25
25
  * @param {number} port
26
26
  * @returns {Promise<import('http').Server>}
27
27
  */
28
- export async function webSync (registry, primaryKeyHex, port, name, keyIterations = 100000) {
28
+ export async function webSync (registry, primaryKeyHex, port, name, keyIterations = 100000, peerOptions = {}) {
29
29
  const app = express()
30
30
 
31
31
  app.use(express.static(publicDir))
@@ -108,7 +108,7 @@ export async function webSync (registry, primaryKeyHex, port, name, keyIteration
108
108
 
109
109
  const server = createServer(app)
110
110
 
111
- attachStreamSync(new WebSocketServer({ server }), registry, 'web')
111
+ attachStreamSync(new WebSocketServer({ server }), registry, 'web', peerOptions)
112
112
 
113
113
  await new Promise((resolve, reject) => {
114
114
  server.listen(port, err => err ? reject(err) : resolve())