//go:generate go run gen/html.go
//go:generate go run gen/scripts.go
//go:generate go fmt
// Package app is a package to build progressive web apps (PWA) with Go
// programming language and WebAssembly.
// It uses a declarative syntax that allows creating and dealing with HTML
// elements only by using Go, and without writing any HTML markup.
// The package also provides an http.Handler ready to serve all the required
// resources to run Go-based progressive web apps.
package app
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"runtime"
"strings"
"time"
"github.com/maxence-charriere/go-app/v9/pkg/errors"
)
const (
// IsClient reports whether the code is running as a client in the
// WebAssembly binary (app.wasm).
IsClient = runtime.GOARCH == "wasm" && runtime.GOOS == "js"
// IsServer reports whether the code is running on a server for
// pre-rendering purposes.
IsServer = runtime.GOARCH != "wasm" || runtime.GOOS != "js"
orientationChangeDelay = time.Millisecond * 500
engineUpdateRate = 120
resizeInterval = time.Millisecond * 250
)
var (
rootPrefix string
isInternalURL func(string) bool
appUpdateAvailable bool
lastURLVisited *url.URL
resizeTimer *time.Timer
)
// Getenv retrieves the value of the environment variable named by the key. It
// returns the value, which will be empty if the variable is not present.
func Getenv(k string) string {
if IsServer {
return os.Getenv(k)
}
env := Window().Call("goappGetenv", k)
if !env.Truthy() {
return ""
}
return env.String()
}
// KeepBodyClean prevents third-party Javascript libraries to add nodes to the
// body element.
func KeepBodyClean() (close func()) {
if IsServer {
return func() {}
}
release := Window().Call("goappKeepBodyClean")
return func() {
release.Invoke()
}
}
// Window returns the JavaScript "window" object.
func Window() BrowserWindow {
return window
}
// RunWhenOnBrowser starts the app, displaying the component associated with the
// current URL path.
//
// This call is skipped when the program is not run on a web browser. This
// allows writing client and server-side code without separation or
// pre-compilation flags.
//
// Eg:
//
// func main() {
// // Define app routes.
// app.Route("/", myComponent{})
// app.Route("/other-page", myOtherComponent{})
//
// // Run the application when on a web browser (only executed on client side).
// app.RunWhenOnBrowser()
//
// // Launch the server that serves the app (only executed on server side):
// http.Handle("/", &app.Handler{Name: "My app"})
// http.ListenAndServe(":8080", nil)
// }
func RunWhenOnBrowser() {
if IsServer {
return
}
defer func() {
err := recover()
displayLoadError(err)
panic(err)
}()
rootPrefix = Getenv("GOAPP_ROOT_PREFIX")
isInternalURL = internalURLChecker()
staticResourcesResolver := newClientStaticResourceResolver(Getenv("GOAPP_STATIC_RESOURCES_URL"))
disp := engine{
FrameRate: engineUpdateRate,
LocalStorage: newJSStorage("localStorage"),
SessionStorage: newJSStorage("sessionStorage"),
StaticResourceResolver: staticResourcesResolver,
ActionHandlers: actionHandlers,
}
disp.Page = browserPage{dispatcher: &disp}
disp.Body = newClientBody(&disp)
disp.init()
defer disp.Close()
window.setBody(disp.Body)
onAchorClick := FuncOf(onAchorClick(&disp))
defer onAchorClick.Release()
Window().Set("onclick", onAchorClick)
onPopState := FuncOf(onPopState(&disp))
defer onPopState.Release()
Window().Set("onpopstate", onPopState)
goappNav := FuncOf(goappNav(&disp))
defer goappNav.Release()
Window().Set("goappNav", goappNav)
onAppUpdate := FuncOf(onAppUpdate(&disp))
defer onAppUpdate.Release()
Window().Set("goappOnUpdate", onAppUpdate)
onAppInstallChange := FuncOf(onAppInstallChange(&disp))
defer onAppInstallChange.Release()
Window().Set("goappOnAppInstallChange", onAppInstallChange)
closeAppResize := Window().AddEventListener("resize", onResize)
defer closeAppResize()
closeAppOrientationChange := Window().AddEventListener("orientationchange", onAppOrientationChange)
defer closeAppOrientationChange()
performNavigate(&disp, Window().URL(), false)
disp.start(context.Background())
}
func displayLoadError(err any) {
loadingLabel := Window().
Get("document").
Call("getElementById", "app-wasm-loader-label")
if !loadingLabel.Truthy() {
return
}
loadingLabel.setInnerText(fmt.Sprint(err))
}
func newClientStaticResourceResolver(staticResourceURL string) func(string) string {
return func(path string) string {
if isRemoteLocation(path) || !isStaticResourcePath(path) {
return path
}
var b strings.Builder
b.WriteString(staticResourceURL)
b.WriteByte('/')
b.WriteString(strings.TrimPrefix(path, "/"))
return b.String()
}
}
func internalURLChecker() func(string) bool {
var urls []string
json.Unmarshal([]byte(Getenv("GOAPP_INTERNAL_URLS")), &urls)
return func(url string) bool {
for _, u := range urls {
if strings.HasPrefix(url, u) {
return true
}
}
return false
}
}
func newClientBody(d Dispatcher) *htmlBody {
ctx, cancel := context.WithCancel(context.Background())
body := &htmlBody{
htmlElement: htmlElement{
tag: "body",
context: ctx,
contextCancel: cancel,
dispatcher: d,
jsElement: Window().Get("document").Get("body"),
},
}
body.setSelf(body)
ctx, cancel = context.WithCancel(context.Background())
content := &htmlDiv{
htmlElement: htmlElement{
tag: "div",
context: ctx,
contextCancel: cancel,
dispatcher: d,
jsElement: body.JSValue().firstElementChild(),
},
}
content.setSelf(content)
content.setParent(body)
body.children = append(body.children, content)
return body
}
func onAchorClick(d Dispatcher) func(Value, []Value) any {
return func(this Value, args []Value) any {
event := Event{Value: args[0]}
elem := event.Get("target")
for {
switch elem.Get("tagName").String() {
case "A":
if meta := event.Get("metaKey"); meta.Truthy() && meta.Bool() {
return nil
}
if ctrl := event.Get("ctrlKey"); ctrl.Truthy() && ctrl.Bool() {
return nil
}
if download := elem.Call("getAttribute", "download"); !download.IsNull() {
return nil
}
event.PreventDefault()
if href := elem.Get("href"); href.Truthy() {
navigate(d, elem.Get("href").String())
}
return nil
case "BODY":
return nil
default:
elem = elem.Get("parentElement")
if !elem.Truthy() {
return nil
}
}
}
}
}
func onPopState(d Dispatcher) func(this Value, args []Value) any {
return func(this Value, args []Value) any {
d.Dispatch(Dispatch{
Mode: Update,
Function: func(ctx Context) {
navigateTo(d, Window().URL(), false)
},
})
return nil
}
}
func goappNav(d Dispatcher) func(this Value, args []Value) any {
return func(this Value, args []Value) any {
navigate(d, args[0].String())
return nil
}
}
func navigate(d Dispatcher, rawURL string) {
u, err := url.Parse(rawURL)
if err != nil {
Log(errors.New("navigating to URL failed").
Tag("url", rawURL).
Wrap(err))
return
}
navigateTo(d, u, true)
}
func navigateTo(d Dispatcher, u *url.URL, updateHistory bool) {
if IsServer {
return
}
if isExternalNavigation(u) {
if rawurl := u.String(); isInternalURL(rawurl) || isMailTo(u) {
Window().Get("location").Set("href", u.String())
} else {
Window().Call("open", rawurl)
}
return
}
luv := lastURLVisited
if u.String() == luv.String() {
return
}
if u.Path == luv.Path && u.Fragment != luv.Fragment {
if updateHistory {
Window().addHistory(u)
} else {
lastURLVisited = u
}
d, ok := d.(ClientDispatcher)
if !ok {
return
}
d.Nav(u)
if isFragmentNavigation(u) {
d.Dispatch(Dispatch{
Mode: Defer,
Function: func(ctx Context) {
Window().ScrollToID(u.Fragment)
},
})
}
return
}
performNavigate(d, u, updateHistory)
}
func performNavigate(d Dispatcher, u *url.URL, updateHistory bool) {
if IsServer {
return
}
path := strings.TrimPrefix(u.Path, rootPrefix)
if path == "" {
path = "/"
}
compo, ok := routes.createComponent(path)
if !ok {
compo = ¬Found{}
}
disp, ok := d.(ClientDispatcher)
if !ok {
return
}
disp.Mount(compo)
if updateHistory {
Window().addHistory(u)
} else {
lastURLVisited = u
}
disp.Nav(u)
if isFragmentNavigation(u) {
d.Dispatch(Dispatch{
Mode: Defer,
Function: func(ctx Context) {
Window().ScrollToID(u.Fragment)
},
})
}
}
func isExternalNavigation(u *url.URL) bool {
switch {
case u.Host != "" && u.Host != Window().URL().Host,
isMailTo(u):
return true
default:
return false
}
}
func isMailTo(u *url.URL) bool {
return u.Scheme == "mailto"
}
func isFragmentNavigation(u *url.URL) bool {
return u.Fragment != ""
}
func onAppUpdate(d ClientDispatcher) func(this Value, args []Value) any {
return func(this Value, args []Value) any {
d.Dispatch(Dispatch{
Mode: Update,
Function: func(ctx Context) {
appUpdateAvailable = true
d.AppUpdate()
ctx.Defer(func(Context) {
Log("app has been updated, reload to see changes")
})
},
})
return nil
}
}
func onAppInstallChange(d ClientDispatcher) func(this Value, args []Value) any {
return func(this Value, args []Value) any {
d.AppInstallChange()
return nil
}
}
func onResize(ctx Context, e Event) {
if resizeTimer != nil {
resizeTimer.Stop()
resizeTimer.Reset(resizeInterval)
return
}
resizeTimer = time.AfterFunc(resizeInterval, func() {
if d, ok := ctx.Dispatcher().(ClientDispatcher); ok {
d.AppResize()
}
})
}
func onAppOrientationChange(ctx Context, e Event) {
if d, ok := ctx.Dispatcher().(ClientDispatcher); ok {
go func() {
time.Sleep(orientationChangeDelay)
d.AppResize()
}()
}
}