main.go raw

   1  //go:build js && wasm
   2  
   3  // Command nostrino-wasm exposes the light client to a browser page.
   4  //
   5  // The page loads the wasm bundle and calls:
   6  //
   7  //	nostrino.sync(uri, chain)   // starts a sync against a nostr+chaindata URI
   8  //	nostrino.status()           // JSON status
   9  //	nostrino.match(scriptHex)   // matching heights from the downloaded filters
  10  //
  11  // The store is in memory in this build. IndexedDB persistence is the next
  12  // layer; the sync logic does not change when it arrives.
  13  package main
  14  
  15  import (
  16  	"context"
  17  	"encoding/hex"
  18  	"encoding/json"
  19  	"errors"
  20  	"net"
  21  	"net/url"
  22  	"sync"
  23  	"syscall/js"
  24  	"time"
  25  
  26  	"git.smesh.lol/nostrino/pkg/chain"
  27  	"git.smesh.lol/nostrino/pkg/idbstore"
  28  	"git.smesh.lol/nostrino/pkg/jsws"
  29  	"git.smesh.lol/nostrino/pkg/neutrino"
  30  	"git.smesh.lol/nostrino/pkg/relay"
  31  	"git.smesh.lol/nostrino/pkg/tunnel"
  32  )
  33  
  34  type state struct {
  35  	mu         sync.Mutex
  36  	running    bool
  37  	status     string
  38  	err        string
  39  	tip        uint32
  40  	tipHash    string
  41  	added      int
  42  	filters    int
  43  	bytes      int
  44  	elapsed    string
  45  	store      neutrino.Store
  46  	matches    []uint32
  47  	cancel     context.CancelFunc
  48  	client     *neutrino.Client
  49  	cancelled  bool
  50  	profile    *chain.Profile
  51  	storeChain string
  52  	persist    bool
  53  	idb        *idbstore.Store
  54  	transport  string
  55  }
  56  
  57  var global state
  58  
  59  func main() {
  60  	js.Global().Set("nostrino", js.ValueOf(map[string]any{
  61  		"sync":    js.FuncOf(jsSync),
  62  		"cancel":  js.FuncOf(jsCancel),
  63  		"status":  js.FuncOf(jsStatus),
  64  		"match":   js.FuncOf(jsMatch),
  65  		"profile": js.FuncOf(jsProfile),
  66  	}))
  67  	select {}
  68  }
  69  
  70  func jsSync(this js.Value, args []js.Value) any {
  71  	if len(args) < 2 {
  72  		global.setErr("sync(uri, chain) needs two arguments")
  73  		return nil
  74  	}
  75  	rawURI, chainName := args[0].String(), args[1].String()
  76  	var filtersFrom uint32
  77  	if len(args) > 2 && args[2].Type() == js.TypeNumber {
  78  		filtersFrom = uint32(args[2].Int())
  79  	}
  80  	global.mu.Lock()
  81  	if global.running {
  82  		global.mu.Unlock()
  83  		return nil
  84  	}
  85  	global.running = true
  86  	global.status = "connecting"
  87  	global.err = ""
  88  	global.matches = nil
  89  	global.cancelled = false
  90  	global.transport = ""
  91  	global.mu.Unlock()
  92  	go runSync(rawURI, chainName, filtersFrom)
  93  	return nil
  94  }
  95  
  96  // jsProfile installs a custom chain profile (XBT or another network) from the
  97  // profile JSON format. It returns an empty string on success or the error.
  98  func jsProfile(this js.Value, args []js.Value) any {
  99  	if len(args) < 1 {
 100  		return "profile(json) needs one argument"
 101  	}
 102  	p, err := chain.ParseProfile([]byte(args[0].String()))
 103  	if err != nil {
 104  		return err.Error()
 105  	}
 106  	global.mu.Lock()
 107  	global.profile = p
 108  	global.mu.Unlock()
 109  	return ""
 110  }
 111  
 112  func jsCancel(this js.Value, args []js.Value) any {
 113  	global.mu.Lock()
 114  	cancel := global.cancel
 115  	client := global.client
 116  	global.cancel = nil
 117  	global.cancelled = true
 118  	global.mu.Unlock()
 119  	if cancel != nil {
 120  		cancel()
 121  	}
 122  	// Closing the peer connection unblocks a read that is waiting on the
 123  	// relay, so cancel does not have to wait for a read deadline.
 124  	if client != nil {
 125  		client.Close()
 126  	}
 127  	return nil
 128  }
 129  
 130  func jsStatus(this js.Value, args []js.Value) any {
 131  	global.mu.Lock()
 132  	defer global.mu.Unlock()
 133  	out, _ := json.Marshal(map[string]any{
 134  		"running":   global.running,
 135  		"status":    global.status,
 136  		"error":     global.err,
 137  		"tip":       global.tip,
 138  		"tipHash":   global.tipHash,
 139  		"added":     global.added,
 140  		"filters":   global.filters,
 141  		"bytes":     global.bytes,
 142  		"elapsed":   global.elapsed,
 143  		"matches":   global.matches,
 144  		"persist":   global.persist,
 145  		"store":     global.storeName(),
 146  		"transport": global.transport,
 147  	})
 148  	return string(out)
 149  }
 150  
 151  func jsMatch(this js.Value, args []js.Value) any {
 152  	if len(args) < 1 {
 153  		return "[]"
 154  	}
 155  	script, err := hex.DecodeString(args[0].String())
 156  	if err != nil {
 157  		return "[]"
 158  	}
 159  	global.mu.Lock()
 160  	store := global.store
 161  	global.mu.Unlock()
 162  	if store == nil {
 163  		return "[]"
 164  	}
 165  	matches, err := neutrino.MatchStoredScripts(store, 0, 0, [][]byte{script})
 166  	if err != nil {
 167  		return "[]"
 168  	}
 169  	out, _ := json.Marshal(matches)
 170  	return string(out)
 171  }
 172  
 173  func withChain(raw, chain string) string {
 174  	u, err := url.Parse(raw)
 175  	if err != nil {
 176  		return raw
 177  	}
 178  	q := u.Query()
 179  	q.Set("chain", chain)
 180  	u.RawQuery = q.Encode()
 181  	return u.String()
 182  }
 183  
 184  func (s *state) storeName() string {
 185  	if s.persist {
 186  		return "indexeddb"
 187  	}
 188  	return "memory"
 189  }
 190  
 191  func runSync(rawURI, chainName string, filtersFrom uint32) {
 192  	defer func() {
 193  		global.mu.Lock()
 194  		global.running = false
 195  		global.mu.Unlock()
 196  	}()
 197  	if global.isCancelled() {
 198  		global.finishCancelled()
 199  		return
 200  	}
 201  	u, err := tunnel.ParseURI(rawURI)
 202  	if err != nil {
 203  		global.setErr("uri: " + err.Error())
 204  		return
 205  	}
 206  	global.mu.Lock()
 207  	custom := global.profile
 208  	global.mu.Unlock()
 209  	var profile *chain.Profile
 210  	if custom != nil && custom.Name == chainName {
 211  		profile = custom
 212  	} else {
 213  		profile, err = chain.ByName(chainName)
 214  		if err != nil {
 215  			global.setErr("chain: " + err.Error())
 216  			return
 217  		}
 218  	}
 219  	if err := profile.Validate(); err != nil {
 220  		global.setErr("chain: " + err.Error())
 221  		return
 222  	}
 223  	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
 224  	defer cancel()
 225  	global.mu.Lock()
 226  	global.cancel = cancel
 227  	global.mu.Unlock()
 228  	defer func() {
 229  		global.mu.Lock()
 230  		global.cancel = nil
 231  		global.mu.Unlock()
 232  	}()
 233  	if global.isCancelled() {
 234  		cancel()
 235  		global.finishCancelled()
 236  		return
 237  	}
 238  
 239  	global.mu.Lock()
 240  	store := global.store
 241  	if global.storeChain != profile.Name {
 242  		store = nil
 243  	}
 244  	global.mu.Unlock()
 245  	var idb *idbstore.Store
 246  	if store == nil {
 247  		openCtx, openCancel := context.WithTimeout(context.Background(), 60*time.Second)
 248  		idb, err = idbstore.Open(openCtx, idbstore.Config{Chain: profile.Name, FiltersFrom: filtersFrom})
 249  		openCancel()
 250  		if err != nil {
 251  			store = neutrino.NewMemory()
 252  			idb = nil
 253  		} else {
 254  			store = idb
 255  		}
 256  		global.mu.Lock()
 257  		global.store = store
 258  		global.storeChain = profile.Name
 259  		global.persist = idb != nil
 260  		global.idb = idb
 261  		global.mu.Unlock()
 262  	} else {
 263  		global.mu.Lock()
 264  		idb = global.idb
 265  		global.mu.Unlock()
 266  	}
 267  	defer func() {
 268  		if idb == nil {
 269  			return
 270  		}
 271  		flushCtx, flushCancel := context.WithTimeout(context.Background(), 30*time.Second)
 272  		defer flushCancel()
 273  		if err := idb.Flush(flushCtx); err != nil {
 274  			global.setErr("persist: " + err.Error())
 275  		}
 276  	}()
 277  
 278  	if global.isCancelled() {
 279  		global.finishCancelled()
 280  		return
 281  	}
 282  	client, err := neutrino.New(neutrino.Config{
 283  		Profile: profile,
 284  		Store:   store,
 285  		Dial: func(ctx context.Context) (net.Conn, error) {
 286  			for _, raw := range u.Bridges {
 287  				c, err := jsws.DialStream(withChain(raw, profile.Name))
 288  				if err == nil {
 289  					global.mu.Lock()
 290  					global.transport = "websocket"
 291  					global.mu.Unlock()
 292  					return c, nil
 293  				}
 294  			}
 295  			global.mu.Lock()
 296  			global.transport = "relay"
 297  			global.mu.Unlock()
 298  			return tunnel.Dial(ctx, tunnel.DialConfig{
 299  				URI:   u,
 300  				Chain: profile.Name,
 301  				TransportFactory: func(url string) (relay.Transport, error) {
 302  					return jsws.Dial(url)
 303  				},
 304  			})
 305  		},
 306  		RequireCompactFilters: true,
 307  		ReadTimeout:           120 * time.Second,
 308  	})
 309  	if err != nil {
 310  		global.setErr("client: " + err.Error())
 311  		return
 312  	}
 313  	defer client.Close()
 314  	global.mu.Lock()
 315  	global.client = client
 316  	global.mu.Unlock()
 317  	if global.isCancelled() {
 318  		client.Close()
 319  		global.finishCancelled()
 320  		return
 321  	}
 322  	defer func() {
 323  		global.mu.Lock()
 324  		global.client = nil
 325  		global.mu.Unlock()
 326  	}()
 327  
 328  	global.setStatus("syncing")
 329  	res, err := client.SyncFrom(ctx, filtersFrom, 0)
 330  	if err == nil && global.isCancelled() {
 331  		global.finishCancelled()
 332  		return
 333  	}
 334  	if err != nil {
 335  		global.mu.Lock()
 336  		cancelled := global.cancelled
 337  		global.mu.Unlock()
 338  		if cancelled || errors.Is(err, context.Canceled) {
 339  			global.mu.Lock()
 340  			global.status = "cancelled"
 341  			global.err = ""
 342  			global.running = false
 343  			global.mu.Unlock()
 344  			return
 345  		}
 346  		global.setErr("sync: " + err.Error())
 347  		return
 348  	}
 349  	if idb != nil {
 350  		flushCtx, flushCancel := context.WithTimeout(context.Background(), 30*time.Second)
 351  		err := idb.Flush(flushCtx)
 352  		flushCancel()
 353  		if err != nil {
 354  			global.setErr("persist: " + err.Error())
 355  			return
 356  		}
 357  	}
 358  	global.mu.Lock()
 359  	global.tip = res.TipHeight
 360  	if tip, h, ok := store.HeaderTip(); ok {
 361  		global.tip = tip
 362  		global.tipHash = h.String()
 363  	}
 364  	global.added = res.HeadersAdded
 365  	global.filters = res.FiltersAdded
 366  	global.bytes = res.FilterBytes
 367  	global.elapsed = res.Elapsed.String()
 368  	global.status = "done"
 369  	global.mu.Unlock()
 370  }
 371  
 372  func (s *state) isCancelled() bool {
 373  	s.mu.Lock()
 374  	defer s.mu.Unlock()
 375  	return s.cancelled
 376  }
 377  
 378  func (s *state) finishCancelled() {
 379  	s.mu.Lock()
 380  	s.status = "cancelled"
 381  	s.err = ""
 382  	s.running = false
 383  	s.mu.Unlock()
 384  }
 385  
 386  func (s *state) setStatus(v string) {
 387  	s.mu.Lock()
 388  	s.status = v
 389  	s.mu.Unlock()
 390  }
 391  
 392  func (s *state) setErr(v string) {
 393  	s.mu.Lock()
 394  	s.err = v
 395  	s.status = "error"
 396  	s.running = false
 397  	s.mu.Unlock()
 398  }
 399