@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.
- package/.claude/settings.json +1 -0
- package/.claude/settings.local.json +5 -1
- package/.env.dev +4 -0
- package/CLAUDE.md +73 -0
- package/README.md +45 -23
- package/ROADMAP.md +85 -50
- package/bin/streamo.js +33 -44
- package/jsconfig.json +3 -2
- package/package.json +4 -3
- package/public/apps/chat/main.js +70 -87
- package/public/apps/chat/server.js +35 -0
- package/public/streamo/Repo.js +39 -1
- package/public/streamo/StreamoComponent.js +92 -0
- package/public/streamo/StreamoServer.js +71 -0
- package/public/streamo/chat-cli.js +4 -3
- package/public/streamo/mount.js +69 -20
- package/public/streamo/webSync.js +2 -2
- package/public/streamo/chat-server.js +0 -60
- package/scripts/serve.js +0 -15
package/public/apps/chat/main.js
CHANGED
|
@@ -1,74 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
62
|
-
}
|
|
63
|
-
rendered = all.length
|
|
64
|
-
if (all.length > rendered - 1) msgsEl.scrollTop = msgsEl.scrollHeight
|
|
21
|
+
</div>
|
|
22
|
+
`
|
|
65
23
|
}
|
|
66
24
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
41
|
+
const signer = new Signer(username, password, 1)
|
|
83
42
|
const { publicKey } = await signer.keysFor('chat')
|
|
84
|
-
myKey
|
|
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:
|
|
90
|
-
follow:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
onAnnounce:
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/public/streamo/Repo.js
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
32
|
-
|
|
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)
|
package/public/streamo/mount.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
67
|
-
node.
|
|
68
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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())
|