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