PLAN.md raw

smesh2 cleanup plan

Phase 0: Channel primitive

A ~30-line module that replaces callback maps, timeout cleanup, and stale closures with linear async flow.

chan.js

// 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.

Phase 1: Split sw.js into modules

Create 5 new files, keeping sw.js as the orchestrator.

crypto.js (~170 lines)

Exports: all functions above. Imports noble-curves/hashes/ciphers.

db.js (~140 lines)

Exports: getDB, saveEvent, queryEvents, saveDM, queryDMs, getConversationList, dmDedupId.

pool.js (~220 lines)

pool.js exports onReconnect(url, fn). dm.js registers its re-sub handler.

dm.js (~180 lines)

sw.js (~100 lines, down from 1139)

Phase 2: Remove Preact, go vanilla JS

Replace Preact+hooks+htm with plain DOM rendering + channel-driven message handling.

Architecture

style.css (~190 lines)

helpers.js (~140 lines)

Imports: nip19Decode from nostr-tools, schnorr + bytesToHex from noble.

state.js (~180 lines)

views.js (~550 lines)

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' })
}

app.js (~150 lines)

index.html (~10 lines)

<!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>

Phase 3: Deploy and verify

Bug fix summary

#BugFix locationFix
1Spread overflow on large payloadscrypto.jsChunked base64 encoder
2DM subs die on WS reconnectpool.js + dm.jsonReconnect callback, re-send subs
3Publish to all relays not writeRelayspool.js handleEventUse writeRelays array
5DM_HISTORY stale closureapp.jsNo closure — reads state at await
6RELAY_INFO stale closureapp.js + state.jsHandled in reducer
7sanitizeFilter empty required fieldspool.jsDrop filter if required array empty
8processNip17DM swallows errorsdm.jsconsole.warn the error
10useEffect stale closuresapp.jsNo useEffect exists — channel listeners
11No NIP-42 AUTHpool.jsSign AUTH challenge, send AUTH event
15Settings logout inconsistentviews.jsClear loginMode in both logout paths

What's NOT changing

File inventory

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)