//go:build js && wasm // Command nostrino-wasm exposes the light client to a browser page. // // The page loads the wasm bundle and calls: // // nostrino.sync(uri, chain) // starts a sync against a nostr+chaindata URI // nostrino.status() // JSON status // nostrino.match(scriptHex) // matching heights from the downloaded filters // // The store is in memory in this build. IndexedDB persistence is the next // layer; the sync logic does not change when it arrives. package main import ( "context" "encoding/hex" "encoding/json" "errors" "net" "net/url" "sync" "syscall/js" "time" "git.smesh.lol/nostrino/pkg/chain" "git.smesh.lol/nostrino/pkg/idbstore" "git.smesh.lol/nostrino/pkg/jsws" "git.smesh.lol/nostrino/pkg/neutrino" "git.smesh.lol/nostrino/pkg/relay" "git.smesh.lol/nostrino/pkg/tunnel" ) type state struct { mu sync.Mutex running bool status string err string tip uint32 tipHash string added int filters int bytes int elapsed string store neutrino.Store matches []uint32 cancel context.CancelFunc client *neutrino.Client cancelled bool profile *chain.Profile storeChain string persist bool idb *idbstore.Store transport string } var global state func main() { js.Global().Set("nostrino", js.ValueOf(map[string]any{ "sync": js.FuncOf(jsSync), "cancel": js.FuncOf(jsCancel), "status": js.FuncOf(jsStatus), "match": js.FuncOf(jsMatch), "profile": js.FuncOf(jsProfile), })) select {} } func jsSync(this js.Value, args []js.Value) any { if len(args) < 2 { global.setErr("sync(uri, chain) needs two arguments") return nil } rawURI, chainName := args[0].String(), args[1].String() var filtersFrom uint32 if len(args) > 2 && args[2].Type() == js.TypeNumber { filtersFrom = uint32(args[2].Int()) } global.mu.Lock() if global.running { global.mu.Unlock() return nil } global.running = true global.status = "connecting" global.err = "" global.matches = nil global.cancelled = false global.transport = "" global.mu.Unlock() go runSync(rawURI, chainName, filtersFrom) return nil } // jsProfile installs a custom chain profile (XBT or another network) from the // profile JSON format. It returns an empty string on success or the error. func jsProfile(this js.Value, args []js.Value) any { if len(args) < 1 { return "profile(json) needs one argument" } p, err := chain.ParseProfile([]byte(args[0].String())) if err != nil { return err.Error() } global.mu.Lock() global.profile = p global.mu.Unlock() return "" } func jsCancel(this js.Value, args []js.Value) any { global.mu.Lock() cancel := global.cancel client := global.client global.cancel = nil global.cancelled = true global.mu.Unlock() if cancel != nil { cancel() } // Closing the peer connection unblocks a read that is waiting on the // relay, so cancel does not have to wait for a read deadline. if client != nil { client.Close() } return nil } func jsStatus(this js.Value, args []js.Value) any { global.mu.Lock() defer global.mu.Unlock() out, _ := json.Marshal(map[string]any{ "running": global.running, "status": global.status, "error": global.err, "tip": global.tip, "tipHash": global.tipHash, "added": global.added, "filters": global.filters, "bytes": global.bytes, "elapsed": global.elapsed, "matches": global.matches, "persist": global.persist, "store": global.storeName(), "transport": global.transport, }) return string(out) } func jsMatch(this js.Value, args []js.Value) any { if len(args) < 1 { return "[]" } script, err := hex.DecodeString(args[0].String()) if err != nil { return "[]" } global.mu.Lock() store := global.store global.mu.Unlock() if store == nil { return "[]" } matches, err := neutrino.MatchStoredScripts(store, 0, 0, [][]byte{script}) if err != nil { return "[]" } out, _ := json.Marshal(matches) return string(out) } func withChain(raw, chain string) string { u, err := url.Parse(raw) if err != nil { return raw } q := u.Query() q.Set("chain", chain) u.RawQuery = q.Encode() return u.String() } func (s *state) storeName() string { if s.persist { return "indexeddb" } return "memory" } func runSync(rawURI, chainName string, filtersFrom uint32) { defer func() { global.mu.Lock() global.running = false global.mu.Unlock() }() if global.isCancelled() { global.finishCancelled() return } u, err := tunnel.ParseURI(rawURI) if err != nil { global.setErr("uri: " + err.Error()) return } global.mu.Lock() custom := global.profile global.mu.Unlock() var profile *chain.Profile if custom != nil && custom.Name == chainName { profile = custom } else { profile, err = chain.ByName(chainName) if err != nil { global.setErr("chain: " + err.Error()) return } } if err := profile.Validate(); err != nil { global.setErr("chain: " + err.Error()) return } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() global.mu.Lock() global.cancel = cancel global.mu.Unlock() defer func() { global.mu.Lock() global.cancel = nil global.mu.Unlock() }() if global.isCancelled() { cancel() global.finishCancelled() return } global.mu.Lock() store := global.store if global.storeChain != profile.Name { store = nil } global.mu.Unlock() var idb *idbstore.Store if store == nil { openCtx, openCancel := context.WithTimeout(context.Background(), 60*time.Second) idb, err = idbstore.Open(openCtx, idbstore.Config{Chain: profile.Name, FiltersFrom: filtersFrom}) openCancel() if err != nil { store = neutrino.NewMemory() idb = nil } else { store = idb } global.mu.Lock() global.store = store global.storeChain = profile.Name global.persist = idb != nil global.idb = idb global.mu.Unlock() } else { global.mu.Lock() idb = global.idb global.mu.Unlock() } defer func() { if idb == nil { return } flushCtx, flushCancel := context.WithTimeout(context.Background(), 30*time.Second) defer flushCancel() if err := idb.Flush(flushCtx); err != nil { global.setErr("persist: " + err.Error()) } }() if global.isCancelled() { global.finishCancelled() return } client, err := neutrino.New(neutrino.Config{ Profile: profile, Store: store, Dial: func(ctx context.Context) (net.Conn, error) { for _, raw := range u.Bridges { c, err := jsws.DialStream(withChain(raw, profile.Name)) if err == nil { global.mu.Lock() global.transport = "websocket" global.mu.Unlock() return c, nil } } global.mu.Lock() global.transport = "relay" global.mu.Unlock() return tunnel.Dial(ctx, tunnel.DialConfig{ URI: u, Chain: profile.Name, TransportFactory: func(url string) (relay.Transport, error) { return jsws.Dial(url) }, }) }, RequireCompactFilters: true, ReadTimeout: 120 * time.Second, }) if err != nil { global.setErr("client: " + err.Error()) return } defer client.Close() global.mu.Lock() global.client = client global.mu.Unlock() if global.isCancelled() { client.Close() global.finishCancelled() return } defer func() { global.mu.Lock() global.client = nil global.mu.Unlock() }() global.setStatus("syncing") res, err := client.SyncFrom(ctx, filtersFrom, 0) if err == nil && global.isCancelled() { global.finishCancelled() return } if err != nil { global.mu.Lock() cancelled := global.cancelled global.mu.Unlock() if cancelled || errors.Is(err, context.Canceled) { global.mu.Lock() global.status = "cancelled" global.err = "" global.running = false global.mu.Unlock() return } global.setErr("sync: " + err.Error()) return } if idb != nil { flushCtx, flushCancel := context.WithTimeout(context.Background(), 30*time.Second) err := idb.Flush(flushCtx) flushCancel() if err != nil { global.setErr("persist: " + err.Error()) return } } global.mu.Lock() global.tip = res.TipHeight if tip, h, ok := store.HeaderTip(); ok { global.tip = tip global.tipHash = h.String() } global.added = res.HeadersAdded global.filters = res.FiltersAdded global.bytes = res.FilterBytes global.elapsed = res.Elapsed.String() global.status = "done" global.mu.Unlock() } func (s *state) isCancelled() bool { s.mu.Lock() defer s.mu.Unlock() return s.cancelled } func (s *state) finishCancelled() { s.mu.Lock() s.status = "cancelled" s.err = "" s.running = false s.mu.Unlock() } func (s *state) setStatus(v string) { s.mu.Lock() s.status = v s.mu.Unlock() } func (s *state) setErr(v string) { s.mu.Lock() s.err = v s.status = "error" s.running = false s.mu.Unlock() }