main.go raw

   1  package main
   2  
   3  import (
   4  	"common/jsbridge/sw"
   5  )
   6  
   7  // App Shell domain — Service Worker lifecycle, static asset caching, SSE version monitoring.
   8  // Thin outer shell: delegates all app-level messages to the Subscription Router.
   9  
  10  const cacheName = "smesh"
  11  
  12  var appFiles = []string{
  13  	// Main app — absolute paths so cache.addAll resolves correctly from SW location.
  14  	// Note: /index.html omitted — Go FileServer 301-redirects it to /, which
  15  	// causes cache.addAll to fail (Cache API rejects redirect responses).
  16  	"/",
  17  	"/$entry.mjs",
  18  	"/smesh3.mjs",
  19  	"/common_crypto_secp256k1.mjs",
  20  	"/common_crypto_sha256.mjs",
  21  	"/common_helpers.mjs",
  22  	"/common_jsbridge_dom.mjs",
  23  	"/common_jsbridge_localstorage.mjs",
  24  	"/common_jsbridge_signer.mjs",
  25  	"/common_nostr.mjs",
  26  	"/$runtime/index.mjs",
  27  	"/$runtime/runtime.mjs",
  28  	"/$runtime/goroutine.mjs",
  29  	"/$runtime/channel.mjs",
  30  	"/$runtime/builtin.mjs",
  31  	"/$runtime/types.mjs",
  32  	"/$runtime/sync.mjs",
  33  	"/$runtime/dom.mjs",
  34  	"/$runtime/localstorage.mjs",
  35  	"/$runtime/signer.mjs",
  36  	"/$wasm/secp256k1.mjs",
  37  	"/$wasm/secp256k1.wasm",
  38  	"/smesh-loader.svg",
  39  	// Service worker (shell SW).
  40  	"/$sw/$entry.mjs",
  41  	"/$sw/sw.mjs",
  42  	"/$sw/common_jsbridge_sw.mjs",
  43  	"/$sw/common_jsbridge_bc.mjs",
  44  	"/$sw/common_jsbridge_subtle.mjs",
  45  	"/$sw/common_crypto_secp256k1.mjs",
  46  	"/$sw/common_crypto_sha256.mjs",
  47  	"/$sw/common_helpers.mjs",
  48  	"/$sw/$runtime/index.mjs",
  49  	"/$sw/$runtime/runtime.mjs",
  50  	"/$sw/$runtime/goroutine.mjs",
  51  	"/$sw/$runtime/channel.mjs",
  52  	"/$sw/$runtime/builtin.mjs",
  53  	"/$sw/$runtime/types.mjs",
  54  	"/$sw/$runtime/sync.mjs",
  55  	"/$sw/$runtime/sw.mjs",
  56  	"/$sw/$runtime/subtle.mjs",
  57  	"/$sw/$runtime/crypto.mjs",
  58  	"/$sw/$runtime/ws.mjs",
  59  	"/$sw/$runtime/bc.mjs",
  60  }
  61  
  62  var currentVersion string
  63  var refreshing bool
  64  
  65  // File categories for smart reload.
  66  const (
  67  	catAppCode = 0 // full page reload
  68  	catSWCode  = 1 // SW re-register via FORCE_UPDATE_SW
  69  	catStatic  = 2 // silent cache update only
  70  )
  71  
  72  func main() {
  73  	initSharedState()
  74  	sw.OnInstall(onInstall)
  75  	sw.OnActivate(onActivate)
  76  	sw.OnFetch(onFetch)
  77  	sw.OnMessage(onMessage)
  78  	// Connect bus+SSE from main() so they survive SW thread eviction.
  79  	// onActivate only fires once per lifecycle; the browser can evict
  80  	// and restart the thread at any time, losing all in-memory state.
  81  	connectSSE()
  82  	connectBus()
  83  }
  84  
  85  func onInstall(event sw.Event) {
  86  	sw.WaitUntil(event, func(done func()) {
  87  		sw.SkipWaiting()
  88  		done()
  89  	})
  90  }
  91  
  92  func onActivate(event sw.Event) {
  93  	sw.WaitUntil(event, func(done func()) {
  94  		// Delete old caches left from previous cache names.
  95  		// caches.match() searches ALL caches — stale entries poison fetches.
  96  		sw.CacheDelete("sm3sh", func() {
  97  			sw.ClaimClients(func() {
  98  				done()
  99  			})
 100  		})
 101  	})
 102  }
 103  
 104  func onFetch(event sw.Event) {
 105  	url := sw.GetRequestURL(event)
 106  	origin := sw.Origin()
 107  	// Only intercept same-origin requests.
 108  	if len(url) < len(origin) || url[:len(origin)] != origin {
 109  		return
 110  	}
 111  	path := sw.GetRequestPath(event)
 112  	if path == "/__sse" || path == "/__version" {
 113  		return
 114  	}
 115  	// SW module files, satellite SW dirs, and fonts: pass through to network.
 116  	if (len(path) > 4 && path[:4] == "/$sw") || (len(path) > 6 && path[:7] == "/fonts/") {
 117  		return
 118  	}
 119  	sw.RespondWithCacheFirst(event)
 120  }
 121  
 122  func onMessage(event sw.Event) {
 123  	data := sw.GetMessageData(event)
 124  	clientID := sw.GetMessageClientID(event)
 125  
 126  	// Simple string messages — App Shell handles directly.
 127  	if data == "activate-update" {
 128  		fullRefresh()
 129  		return
 130  	}
 131  	if data == "CLAIM" {
 132  		// Re-claim after hard refresh (onActivate doesn't re-fire).
 133  		sw.ClaimClients(func() {})
 134  		return
 135  	}
 136  
 137  	// JSON array messages — parse and route.
 138  	w := newMW(data)
 139  	msgType := w.str()
 140  
 141  	switch msgType {
 142  	case "SKIP_WAITING":
 143  		sw.SkipWaiting()
 144  	default:
 145  		routeMessage(clientID, &w, msgType)
 146  	}
 147  }
 148  
 149  func connectSSE() {
 150  	sw.SSEConnect("/__sse", func(data string) {
 151  		v := jsonFieldRaw(data, "v")
 152  		if v == "" {
 153  			// Old-format SSE (plain number) — treat entire data as version.
 154  			v = data
 155  		}
 156  		if v == "" {
 157  			return
 158  		}
 159  		if currentVersion == "" {
 160  			currentVersion = v
 161  			// First connect: populate cache silently — page is already loading.
 162  			populateCache()
 163  			return
 164  		}
 165  		if v != currentVersion && !refreshing {
 166  			currentVersion = v
 167  			refreshing = true
 168  			raw := jsonFieldRaw(data, "files")
 169  			if raw == "" {
 170  				fullRefresh()
 171  			} else {
 172  				w := mw{s: raw, i: 0}
 173  				files := w.strs()
 174  				if len(files) == 0 {
 175  					fullRefresh()
 176  				} else {
 177  					smartRefresh(files)
 178  				}
 179  			}
 180  		}
 181  	})
 182  }
 183  
 184  func categorize(path string) int {
 185  	if len(path) > 4 && path[:4] == "/$sw" {
 186  		return catSWCode
 187  	}
 188  	if hasSuffix(path, ".svg") || hasSuffix(path, ".css") ||
 189  		hasSuffix(path, ".woff2") || hasSuffix(path, ".html") {
 190  		return catStatic
 191  	}
 192  	return catAppCode
 193  }
 194  
 195  func hasSuffix(s, suffix string) bool {
 196  	return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
 197  }
 198  
 199  func smartRefresh(files []string) {
 200  	needReload := false
 201  	needSWUpdate := false
 202  	var cacheFiles []string
 203  
 204  	for _, f := range files {
 205  		switch categorize(f) {
 206  		case catAppCode:
 207  			needReload = true
 208  			cacheFiles = append(cacheFiles, f)
 209  		case catSWCode:
 210  			needSWUpdate = true
 211  			// SW files pass through to network, no cache entry.
 212  		case catStatic:
 213  			cacheFiles = append(cacheFiles, f)
 214  		}
 215  	}
 216  
 217  	after := func() {
 218  		if needReload {
 219  			doNavigate()
 220  		} else if needSWUpdate {
 221  			broadcastToClients(`["FORCE_UPDATE_SW","$sw-relay"]`)
 222  		}
 223  	}
 224  
 225  	if len(cacheFiles) > 0 {
 226  		refreshChanged(cacheFiles, after)
 227  	} else {
 228  		after()
 229  	}
 230  }
 231  
 232  func refreshChanged(files []string, done func()) {
 233  	once := false
 234  	finish := func() {
 235  		if once {
 236  			return
 237  		}
 238  		once = true
 239  		refreshing = false
 240  		done()
 241  	}
 242  	sw.SetTimeout(5000, finish)
 243  
 244  	sw.CacheOpen(cacheName, func(cache sw.Cache) {
 245  		urls := make([]string, len(files))
 246  		for i, f := range files {
 247  			urls[i] = f + "?v=" + currentVersion
 248  		}
 249  		pending := 0
 250  		fetchesDone := false
 251  		sw.FetchAll(urls, func(idx int, resp sw.Response, ok bool) {
 252  			if ok && sw.ResponseOK(resp) {
 253  				pending++
 254  				sw.CachePut(cache, files[idx], resp, func() {
 255  					pending--
 256  					if fetchesDone && pending == 0 {
 257  						finish()
 258  					}
 259  				})
 260  			}
 261  		}, func() {
 262  			fetchesDone = true
 263  			if pending == 0 {
 264  				finish()
 265  			}
 266  		})
 267  	})
 268  }
 269  
 270  func populateCache() {
 271  	cacheAll(func() {})
 272  }
 273  
 274  func fullRefresh() {
 275  	cacheAll(doNavigate)
 276  }
 277  
 278  func cacheAll(done func()) {
 279  	once := false
 280  	finish := func() {
 281  		if once {
 282  			return
 283  		}
 284  		once = true
 285  		refreshing = false
 286  		done()
 287  	}
 288  	sw.SetTimeout(8000, finish)
 289  
 290  	sw.CacheOpen(cacheName, func(cache sw.Cache) {
 291  		urls := make([]string, len(appFiles))
 292  		for i, f := range appFiles {
 293  			urls[i] = f + "?v=" + currentVersion
 294  		}
 295  		pending := 0
 296  		fetchesDone := false
 297  		sw.FetchAll(urls, func(idx int, resp sw.Response, ok bool) {
 298  			if ok && sw.ResponseOK(resp) {
 299  				pending++
 300  				sw.CachePut(cache, appFiles[idx], resp, func() {
 301  					pending--
 302  					if fetchesDone && pending == 0 {
 303  						finish()
 304  					}
 305  				})
 306  			}
 307  		}, func() {
 308  			fetchesDone = true
 309  			if pending == 0 {
 310  				finish()
 311  			}
 312  		})
 313  	})
 314  }
 315  
 316  func doNavigate() {
 317  	sw.MatchClients(func(client sw.Client) {
 318  		sw.Navigate(client, "")
 319  	})
 320  }
 321