app.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. //go:generate go run gen/html.go
  2. //go:generate go run gen/scripts.go
  3. //go:generate go fmt
  4. // Package app is a package to build progressive web apps (PWA) with Go
  5. // programming language and WebAssembly.
  6. // It uses a declarative syntax that allows creating and dealing with HTML
  7. // elements only by using Go, and without writing any HTML markup.
  8. // The package also provides an http.Handler ready to serve all the required
  9. // resources to run Go-based progressive web apps.
  10. package app
  11. import (
  12. "context"
  13. "encoding/json"
  14. "fmt"
  15. "net/url"
  16. "os"
  17. "runtime"
  18. "strings"
  19. "time"
  20. "github.com/maxence-charriere/go-app/v9/pkg/errors"
  21. )
  22. const (
  23. // IsClient reports whether the code is running as a client in the
  24. // WebAssembly binary (app.wasm).
  25. IsClient = runtime.GOARCH == "wasm" && runtime.GOOS == "js"
  26. // IsServer reports whether the code is running on a server for
  27. // pre-rendering purposes.
  28. IsServer = runtime.GOARCH != "wasm" || runtime.GOOS != "js"
  29. orientationChangeDelay = time.Millisecond * 500
  30. engineUpdateRate = 120
  31. resizeInterval = time.Millisecond * 250
  32. )
  33. var (
  34. rootPrefix string
  35. isInternalURL func(string) bool
  36. appUpdateAvailable bool
  37. lastURLVisited *url.URL
  38. resizeTimer *time.Timer
  39. )
  40. // Getenv retrieves the value of the environment variable named by the key. It
  41. // returns the value, which will be empty if the variable is not present.
  42. func Getenv(k string) string {
  43. if IsServer {
  44. return os.Getenv(k)
  45. }
  46. env := Window().Call("goappGetenv", k)
  47. if !env.Truthy() {
  48. return ""
  49. }
  50. return env.String()
  51. }
  52. // KeepBodyClean prevents third-party Javascript libraries to add nodes to the
  53. // body element.
  54. func KeepBodyClean() (close func()) {
  55. if IsServer {
  56. return func() {}
  57. }
  58. release := Window().Call("goappKeepBodyClean")
  59. return func() {
  60. release.Invoke()
  61. }
  62. }
  63. // Window returns the JavaScript "window" object.
  64. func Window() BrowserWindow {
  65. return window
  66. }
  67. // RunWhenOnBrowser starts the app, displaying the component associated with the
  68. // current URL path.
  69. //
  70. // This call is skipped when the program is not run on a web browser. This
  71. // allows writing client and server-side code without separation or
  72. // pre-compilation flags.
  73. //
  74. // Eg:
  75. //
  76. // func main() {
  77. // // Define app routes.
  78. // app.Route("/", myComponent{})
  79. // app.Route("/other-page", myOtherComponent{})
  80. //
  81. // // Run the application when on a web browser (only executed on client side).
  82. // app.RunWhenOnBrowser()
  83. //
  84. // // Launch the server that serves the app (only executed on server side):
  85. // http.Handle("/", &app.Handler{Name: "My app"})
  86. // http.ListenAndServe(":8080", nil)
  87. // }
  88. func RunWhenOnBrowser() {
  89. if IsServer {
  90. return
  91. }
  92. defer func() {
  93. err := recover()
  94. displayLoadError(err)
  95. panic(err)
  96. }()
  97. rootPrefix = Getenv("GOAPP_ROOT_PREFIX")
  98. isInternalURL = internalURLChecker()
  99. staticResourcesResolver := newClientStaticResourceResolver(Getenv("GOAPP_STATIC_RESOURCES_URL"))
  100. disp := engine{
  101. FrameRate: engineUpdateRate,
  102. LocalStorage: newJSStorage("localStorage"),
  103. SessionStorage: newJSStorage("sessionStorage"),
  104. StaticResourceResolver: staticResourcesResolver,
  105. ActionHandlers: actionHandlers,
  106. }
  107. disp.Page = browserPage{dispatcher: &disp}
  108. disp.Body = newClientBody(&disp)
  109. disp.init()
  110. defer disp.Close()
  111. window.setBody(disp.Body)
  112. onAchorClick := FuncOf(onAchorClick(&disp))
  113. defer onAchorClick.Release()
  114. Window().Set("onclick", onAchorClick)
  115. onPopState := FuncOf(onPopState(&disp))
  116. defer onPopState.Release()
  117. Window().Set("onpopstate", onPopState)
  118. goappNav := FuncOf(goappNav(&disp))
  119. defer goappNav.Release()
  120. Window().Set("goappNav", goappNav)
  121. onAppUpdate := FuncOf(onAppUpdate(&disp))
  122. defer onAppUpdate.Release()
  123. Window().Set("goappOnUpdate", onAppUpdate)
  124. onAppInstallChange := FuncOf(onAppInstallChange(&disp))
  125. defer onAppInstallChange.Release()
  126. Window().Set("goappOnAppInstallChange", onAppInstallChange)
  127. closeAppResize := Window().AddEventListener("resize", onResize)
  128. defer closeAppResize()
  129. closeAppOrientationChange := Window().AddEventListener("orientationchange", onAppOrientationChange)
  130. defer closeAppOrientationChange()
  131. performNavigate(&disp, Window().URL(), false)
  132. disp.start(context.Background())
  133. }
  134. func displayLoadError(err any) {
  135. loadingLabel := Window().
  136. Get("document").
  137. Call("getElementById", "app-wasm-loader-label")
  138. if !loadingLabel.Truthy() {
  139. return
  140. }
  141. loadingLabel.setInnerText(fmt.Sprint(err))
  142. }
  143. func newClientStaticResourceResolver(staticResourceURL string) func(string) string {
  144. return func(path string) string {
  145. if isRemoteLocation(path) || !isStaticResourcePath(path) {
  146. return path
  147. }
  148. var b strings.Builder
  149. b.WriteString(staticResourceURL)
  150. b.WriteByte('/')
  151. b.WriteString(strings.TrimPrefix(path, "/"))
  152. return b.String()
  153. }
  154. }
  155. func internalURLChecker() func(string) bool {
  156. var urls []string
  157. json.Unmarshal([]byte(Getenv("GOAPP_INTERNAL_URLS")), &urls)
  158. return func(url string) bool {
  159. for _, u := range urls {
  160. if strings.HasPrefix(url, u) {
  161. return true
  162. }
  163. }
  164. return false
  165. }
  166. }
  167. func newClientBody(d Dispatcher) *htmlBody {
  168. ctx, cancel := context.WithCancel(context.Background())
  169. body := &htmlBody{
  170. htmlElement: htmlElement{
  171. tag: "body",
  172. context: ctx,
  173. contextCancel: cancel,
  174. dispatcher: d,
  175. jsElement: Window().Get("document").Get("body"),
  176. },
  177. }
  178. body.setSelf(body)
  179. ctx, cancel = context.WithCancel(context.Background())
  180. content := &htmlDiv{
  181. htmlElement: htmlElement{
  182. tag: "div",
  183. context: ctx,
  184. contextCancel: cancel,
  185. dispatcher: d,
  186. jsElement: body.JSValue().firstElementChild(),
  187. },
  188. }
  189. content.setSelf(content)
  190. content.setParent(body)
  191. body.children = append(body.children, content)
  192. return body
  193. }
  194. func onAchorClick(d Dispatcher) func(Value, []Value) any {
  195. return func(this Value, args []Value) any {
  196. event := Event{Value: args[0]}
  197. elem := event.Get("target")
  198. for {
  199. switch elem.Get("tagName").String() {
  200. case "A":
  201. if meta := event.Get("metaKey"); meta.Truthy() && meta.Bool() {
  202. return nil
  203. }
  204. if ctrl := event.Get("ctrlKey"); ctrl.Truthy() && ctrl.Bool() {
  205. return nil
  206. }
  207. if download := elem.Call("getAttribute", "download"); !download.IsNull() {
  208. return nil
  209. }
  210. event.PreventDefault()
  211. if href := elem.Get("href"); href.Truthy() {
  212. navigate(d, elem.Get("href").String())
  213. }
  214. return nil
  215. case "BODY":
  216. return nil
  217. default:
  218. elem = elem.Get("parentElement")
  219. if !elem.Truthy() {
  220. return nil
  221. }
  222. }
  223. }
  224. }
  225. }
  226. func onPopState(d Dispatcher) func(this Value, args []Value) any {
  227. return func(this Value, args []Value) any {
  228. d.Dispatch(Dispatch{
  229. Mode: Update,
  230. Function: func(ctx Context) {
  231. navigateTo(d, Window().URL(), false)
  232. },
  233. })
  234. return nil
  235. }
  236. }
  237. func goappNav(d Dispatcher) func(this Value, args []Value) any {
  238. return func(this Value, args []Value) any {
  239. navigate(d, args[0].String())
  240. return nil
  241. }
  242. }
  243. func navigate(d Dispatcher, rawURL string) {
  244. u, err := url.Parse(rawURL)
  245. if err != nil {
  246. Log(errors.New("navigating to URL failed").
  247. Tag("url", rawURL).
  248. Wrap(err))
  249. return
  250. }
  251. navigateTo(d, u, true)
  252. }
  253. func navigateTo(d Dispatcher, u *url.URL, updateHistory bool) {
  254. if IsServer {
  255. return
  256. }
  257. if isExternalNavigation(u) {
  258. if rawurl := u.String(); isInternalURL(rawurl) || isMailTo(u) {
  259. Window().Get("location").Set("href", u.String())
  260. } else {
  261. Window().Call("open", rawurl)
  262. }
  263. return
  264. }
  265. luv := lastURLVisited
  266. if u.String() == luv.String() {
  267. return
  268. }
  269. if u.Path == luv.Path && u.Fragment != luv.Fragment {
  270. if updateHistory {
  271. Window().addHistory(u)
  272. } else {
  273. lastURLVisited = u
  274. }
  275. d, ok := d.(ClientDispatcher)
  276. if !ok {
  277. return
  278. }
  279. d.Nav(u)
  280. if isFragmentNavigation(u) {
  281. d.Dispatch(Dispatch{
  282. Mode: Defer,
  283. Function: func(ctx Context) {
  284. Window().ScrollToID(u.Fragment)
  285. },
  286. })
  287. }
  288. return
  289. }
  290. performNavigate(d, u, updateHistory)
  291. }
  292. func performNavigate(d Dispatcher, u *url.URL, updateHistory bool) {
  293. if IsServer {
  294. return
  295. }
  296. path := strings.TrimPrefix(u.Path, rootPrefix)
  297. if path == "" {
  298. path = "/"
  299. }
  300. compo, ok := routes.createComponent(path)
  301. if !ok {
  302. compo = &notFound{}
  303. }
  304. disp, ok := d.(ClientDispatcher)
  305. if !ok {
  306. return
  307. }
  308. disp.Mount(compo)
  309. if updateHistory {
  310. Window().addHistory(u)
  311. } else {
  312. lastURLVisited = u
  313. }
  314. disp.Nav(u)
  315. if isFragmentNavigation(u) {
  316. d.Dispatch(Dispatch{
  317. Mode: Defer,
  318. Function: func(ctx Context) {
  319. Window().ScrollToID(u.Fragment)
  320. },
  321. })
  322. }
  323. }
  324. func isExternalNavigation(u *url.URL) bool {
  325. switch {
  326. case u.Host != "" && u.Host != Window().URL().Host,
  327. isMailTo(u):
  328. return true
  329. default:
  330. return false
  331. }
  332. }
  333. func isMailTo(u *url.URL) bool {
  334. return u.Scheme == "mailto"
  335. }
  336. func isFragmentNavigation(u *url.URL) bool {
  337. return u.Fragment != ""
  338. }
  339. func onAppUpdate(d ClientDispatcher) func(this Value, args []Value) any {
  340. return func(this Value, args []Value) any {
  341. d.Dispatch(Dispatch{
  342. Mode: Update,
  343. Function: func(ctx Context) {
  344. appUpdateAvailable = true
  345. d.AppUpdate()
  346. ctx.Defer(func(Context) {
  347. Log("app has been updated, reload to see changes")
  348. })
  349. },
  350. })
  351. return nil
  352. }
  353. }
  354. func onAppInstallChange(d ClientDispatcher) func(this Value, args []Value) any {
  355. return func(this Value, args []Value) any {
  356. d.AppInstallChange()
  357. return nil
  358. }
  359. }
  360. func onResize(ctx Context, e Event) {
  361. if resizeTimer != nil {
  362. resizeTimer.Stop()
  363. resizeTimer.Reset(resizeInterval)
  364. return
  365. }
  366. resizeTimer = time.AfterFunc(resizeInterval, func() {
  367. if d, ok := ctx.Dispatcher().(ClientDispatcher); ok {
  368. d.AppResize()
  369. }
  370. })
  371. }
  372. func onAppOrientationChange(ctx Context, e Event) {
  373. if d, ok := ctx.Dispatcher().(ClientDispatcher); ok {
  374. go func() {
  375. time.Sleep(orientationChangeDelay)
  376. d.AppResize()
  377. }()
  378. }
  379. }