A ~30-line module that replaces callback maps, timeout cleanup, and stale closures with linear async flow.
// broadcast channel: multiple listeners, each gets every message
function chan() {
const waiters = []
return {
send(val) {
for (const w of waiters.splice(0)) w(val)
},
recv() {
return new Promise(resolve => waiters.push(resolve))
}
}
}
// multiplexed channel: keyed by request ID, one-shot per key
function mux() {
const pending = new Map()
return {
send(id, val) {
const resolve = pending.get(id)
if (resolve) { pending.delete(id); resolve(val) }
},
recv(id, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error('timeout')) }, timeoutMs)
pending.set(id, (val) => { clearTimeout(timer); resolve(val) })
})
}
}
}
export { chan, mux }
Used in both SW and UI:
SW side (dm.js):
const cryptoMux = mux()
// requestCrypto becomes:
async function requestCrypto(type, pubkey, text) {
const id = ++cryptoRequestId
broadcastToClients([type, id, pubkey, text])
return cryptoMux.recv(id)
}
// CRYPTO_RESULT handler becomes:
cryptoMux.send(requestId, { result, error })
UI side (app.js):
const signMux = mux()
const swChan = chan() // broadcast: every SW message goes here
// signViaSW becomes:
async function signViaSW(event) {
const id = ++signCounter
send(['SIGN', id, event])
return signMux.recv(id)
}
// message handler — one line, no switch statement for routing
navigator.serviceWorker.addEventListener('message', e => {
const [type, ...args] = e.data
swChan.send({ type, args }) // broadcast to all listeners
if (type === 'SIGNED') signMux.send(args[0], args[1])
// etc for other muxed channels
})
// DM listener — reads state at consumption time, never stale
async function listenDMs() {
while (true) {
const msg = await swChan.recv()
if (msg.type !== 'DM_RECEIVED') continue
const dm = msg.args[0]
dispatch({ type: 'ADD_CONVERSATION', conversation: { peer: dm.peer, lastMessage: dm.content.slice(0, 80), lastTs: dm.created_at, from: dm.from } })
dispatch({ type: 'ADD_DM_MESSAGE', message: dm })
}
}
The broadcast channel (chan) replaces useEffect message handlers. The multiplexed channel (mux) replaces callback maps with timeouts. Both are tiny, zero-dependency, and eliminate the stale closure disease at the root.
Create 5 new files, keeping sw.js as the orchestrator.
btoa(String.fromCharCode(...payload)) with chunked encoderExports: all functions above. Imports noble-curves/hashes/ciphers.
Exports: getDB, saveEvent, queryEvents, saveDM, queryDMs, getConversationList, dmDedupId.
pool.js exports onReconnect(url, fn). dm.js registers its re-sub handler.
Replace Preact+hooks+htm with plain DOM rendering + channel-driven message handling.
state object (same shape as current useState initial value)dispatch(action) calls reducer, updates state, calls render()render() reads state.activeTab and calls the matching view function#app for clicks, inputs, submits, keydowns<style> block verbatim. No changes to CSS.Imports: nip19Decode from nostr-tools, schnorr + bytesToHex from noble.
All view rendering functions:
Each function takes state (or relevant slice) and returns HTML string. render() in state.js sets innerHTML on the appropriate container, then binds any interactive elements.
For hot-path updates (new DM bubble, new feed note), use targeted DOM insertion instead of full re-render:
// append DM bubble without re-rendering the entire chat
function appendDMBubble(dm) {
const container = document.querySelector('.dm-messages')
if (!container) return
const el = document.createElement('div')
el.innerHTML = renderDMBubble(dm)
container.querySelector('.dm-end-marker')?.before(el.firstChild)
el.firstChild?.scrollIntoView({ behavior: 'smooth' })
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>smesh</title>
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<link rel="icon" href="./favicon.png" sizes="256x256" type="image/png" />
<link rel="icon" href="./favicon-96x96.png" sizes="96x96" type="image/png" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app"><div class="loading">loading...</div></div>
<script type="module" src="./app.js"></script>
</body>
</html>
//go:embed smesh2)| # | Bug | Fix location | Fix |
|---|---|---|---|
| 1 | Spread overflow on large payloads | crypto.js | Chunked base64 encoder |
| 2 | DM subs die on WS reconnect | pool.js + dm.js | onReconnect callback, re-send subs |
| 3 | Publish to all relays not writeRelays | pool.js handleEvent | Use writeRelays array |
| 5 | DM_HISTORY stale closure | app.js | No closure — reads state at await |
| 6 | RELAY_INFO stale closure | app.js + state.js | Handled in reducer |
| 7 | sanitizeFilter empty required fields | pool.js | Drop filter if required array empty |
| 8 | processNip17DM swallows errors | dm.js | console.warn the error |
| 10 | useEffect stale closures | app.js | No useEffect exists — channel listeners |
| 11 | No NIP-42 AUTH | pool.js | Sign AUTH challenge, send AUTH event |
| 15 | Settings logout inconsistent | views.js | Clear loginMode in both logout paths |
Before: 2 files (sw.js 1139 lines, index.html 1925 lines) = 3064 lines After: 12 files, estimated ~1880 lines total
app/smesh2/
index.html ~10 lines (shell)
style.css ~190 lines (extracted verbatim)
chan.js ~30 lines (channel primitives)
app.js ~150 lines (boot, message routing, event delegation)
state.js ~180 lines (reducer, dispatch, initial state)
views.js ~550 lines (all view render functions)
helpers.js ~140 lines (nostr utils, crypto helpers, content parser)
sw.js ~100 lines (lifecycle, dispatch, identity broadcast)
crypto.js ~170 lines (NIP-04, NIP-44, signing)
db.js ~140 lines (IndexedDB operations)
pool.js ~220 lines (WebSocket pool, relay messaging, AUTH, reconnect)
dm.js ~180 lines (DM processing, sending, subscriptions)