web/main: automatically reload page if version changes

This commit is contained in:
Tulir Asokan 2024-12-07 16:12:47 +02:00
parent 5ee25b83d4
commit 5638adf6bc
9 changed files with 124 additions and 17 deletions

View file

@ -81,6 +81,9 @@ func (c *CommandHandler) Init() {
Data: marshaledPayload, Data: marshaledPayload,
}) })
} }
if ctx.Err() != nil {
return
}
c.App.EmitEvent("hicli_event", &hicli.JSONCommand{ c.App.EmitEvent("hicli_event", &hicli.JSONCommand{
Command: "init_complete", Command: "init_complete",
RequestID: 0, RequestID: 0,

View file

@ -56,6 +56,8 @@ type Gomuks struct {
LogDir string LogDir string
FrontendFS embed.FS FrontendFS embed.FS
indexWithETag []byte
frontendETag string
Config Config Config Config
DisableAuth bool DisableAuth bool

View file

@ -17,15 +17,19 @@
package gomuks package gomuks
import ( import (
"bytes"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
_ "net/http/pprof" _ "net/http/pprof"
"strconv"
"strings" "strings"
"time" "time"
@ -73,6 +77,25 @@ func (gmx *Gomuks) StartServer() {
gmx.Log.Warn().Msg("Frontend not found") gmx.Log.Warn().Msg("Frontend not found")
} else { } else {
router.Handle("/", gmx.FrontendCacheMiddleware(http.FileServerFS(frontend))) router.Handle("/", gmx.FrontendCacheMiddleware(http.FileServerFS(frontend)))
if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() {
gmx.frontendETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339))
indexFile, err := frontend.Open("index.html")
if err != nil {
gmx.Log.Err(err).Msg("Failed to open index.html")
} else {
data, err := io.ReadAll(indexFile)
_ = indexFile.Close()
if err == nil {
gmx.indexWithETag = bytes.Replace(
data,
[]byte("<!-- etag placeholder -->"),
[]byte(fmt.Sprintf(`<meta name="gomuks-frontend-etag" content="%s">`, html.EscapeString(gmx.frontendETag))),
1,
)
}
}
}
} }
gmx.Server = &http.Server{ gmx.Server = &http.Server{
Addr: gmx.Config.Web.ListenAddress, Addr: gmx.Config.Web.ListenAddress,
@ -88,20 +111,23 @@ func (gmx *Gomuks) StartServer() {
} }
func (gmx *Gomuks) FrontendCacheMiddleware(next http.Handler) http.Handler { func (gmx *Gomuks) FrontendCacheMiddleware(next http.Handler) http.Handler {
var frontendCacheETag string
if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() {
frontendCacheETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339))
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == frontendCacheETag { if gmx.frontendETag != "" && r.Header.Get("If-None-Match") == gmx.frontendETag {
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return return
} }
if strings.HasPrefix(r.URL.Path, "/assets/") { if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "max-age=604800, immutable") w.Header().Set("Cache-Control", "max-age=604800, immutable")
} }
if frontendCacheETag != "" { if gmx.frontendETag != "" {
w.Header().Set("ETag", frontendCacheETag) w.Header().Set("ETag", gmx.frontendETag)
if r.URL.Path == "/" && gmx.indexWithETag != nil {
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", strconv.Itoa(len(gmx.indexWithETag)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(gmx.indexWithETag)
return
}
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })

View file

@ -59,6 +59,11 @@ type PingRequestData struct {
var runID = time.Now().UnixNano() var runID = time.Now().UnixNano()
type RunData struct {
RunID string `json:"run_id"`
ETag string `json:"etag"`
}
func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var conn *websocket.Conn var conn *websocket.Conn
log := zerolog.Ctx(r.Context()) log := zerolog.Ctx(r.Context())
@ -233,9 +238,12 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command") log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
} }
} }
initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[string]{ initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*RunData]{
Command: "run_id", Command: "run_id",
Data: strconv.FormatInt(runID, 10), Data: &RunData{
RunID: strconv.FormatInt(runID, 10),
ETag: gmx.frontendETag,
},
}) })
if initErr != nil { if initErr != nil {
log.Err(initErr).Msg("Failed to write init client state message") log.Err(initErr).Msg("Failed to write init client state message")
@ -315,6 +323,9 @@ func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
return return
} }
} }
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, &hicli.JSONCommand{ err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "init_complete", Command: "init_complete",
RequestID: 0, RequestID: 0,

View file

@ -23,6 +23,9 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID) ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get room account data") zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get room account data")
if ctx.Err() != nil {
return nil
}
syncRoom.AccountData = make(map[event.Type]*database.AccountData) syncRoom.AccountData = make(map[event.Type]*database.AccountData)
} else { } else {
syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad)) syncRoom.AccountData = make(map[event.Type]*database.AccountData, len(ad))
@ -34,6 +37,9 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
previewEvent, err := h.DB.Event.GetByRowID(ctx, room.PreviewEventRowID) previewEvent, err := h.DB.Event.GetByRowID(ctx, room.PreviewEventRowID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get preview event for room") zerolog.Ctx(ctx).Err(err).Stringer("room_id", room.ID).Msg("Failed to get preview event for room")
if ctx.Err() != nil {
return nil
}
} else if previewEvent != nil { } else if previewEvent != nil {
h.ReprocessExistingEvent(ctx, previewEvent) h.ReprocessExistingEvent(ctx, previewEvent)
previewMember, err := h.DB.CurrentState.Get(ctx, room.ID, event.StateMember, previewEvent.Sender.String()) previewMember, err := h.DB.CurrentState.Get(ctx, room.ID, event.StateMember, previewEvent.Sender.String())
@ -85,6 +91,9 @@ func (h *HiClient) GetInitialSync(ctx context.Context, batchSize int) iter.Seq[*
} }
maxTS = room.SortingTimestamp.Time maxTS = room.SortingTimestamp.Time
payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room) payload.Rooms[room.ID] = h.getInitialSyncRoom(ctx, room)
if ctx.Err() != nil {
return
}
} }
if !yield(&payload) || len(rooms) < batchSize { if !yield(&payload) || len(rooms) < batchSize {
break break

View file

@ -5,6 +5,7 @@
<link rel="icon" type="image/png" href="gomuks.png"/> <link rel="icon" type="image/png" href="gomuks.png"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>gomuks web</title> <title>gomuks web</title>
<!-- etag placeholder -->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -124,7 +124,12 @@ export interface InitCompleteEvent extends BaseRPCCommand<void> {
command: "init_complete" command: "init_complete"
} }
export interface RunIDEvent extends BaseRPCCommand<string> { export interface RunData {
run_id: string
etag: string
}
export interface RunIDEvent extends BaseRPCCommand<RunData> {
command: "run_id" command: "run_id"
} }

View file

@ -19,6 +19,35 @@ import type { RPCCommand } from "./types"
const PING_INTERVAL = 15_000 const PING_INTERVAL = 15_000
const RECV_TIMEOUT = 4 * PING_INTERVAL const RECV_TIMEOUT = 4 * PING_INTERVAL
function checkUpdate(etag: string) {
if (!import.meta.env.PROD) {
return
} else if (!etag) {
console.log("Not checking for update, frontend etag not found in websocket init")
return
}
const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
)?.content
if (!currentETag) {
console.log("Not checking for update, frontend etag not found in head")
} else if (currentETag === etag) {
console.log("Frontend is up to date")
} else if (localStorage.lastUpdateTo === etag) {
console.warn(
`Frontend etag mismatch ${currentETag} !== ${etag}, `,
"but localstorage says an update was already attempted",
)
} else {
console.info(`Frontend etag mismatch ${currentETag} !== ${etag}, reloading`)
localStorage.lastUpdateTo = etag
location.search = "?" + new URLSearchParams({
updateTo: etag,
state: JSON.stringify(history.state),
})
}
}
export default class WSClient extends RPCClient { export default class WSClient extends RPCClient {
#conn: WebSocket | null = null #conn: WebSocket | null = null
#lastMessage: number = 0 #lastMessage: number = 0
@ -105,7 +134,9 @@ export default class WSClient extends RPCClient {
if (parsed.request_id < 0) { if (parsed.request_id < 0) {
this.#lastReceivedEvt = parsed.request_id this.#lastReceivedEvt = parsed.request_id
} else if (parsed.command === "run_id") { } else if (parsed.command === "run_id") {
this.#resumeRunID = parsed.data console.log("Received run ID", parsed.data)
this.#resumeRunID = parsed.data.run_id
checkUpdate(parsed.data.etag)
} }
this.onCommand(parsed) this.onCommand(parsed)
} }

View file

@ -151,17 +151,35 @@ const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const handleURLHash = (client: Client, context: ContextFields) => { const handleURLHash = (client: Client, context: ContextFields) => {
if (!location.hash.startsWith("#/uri/")) { if (!location.hash.startsWith("#/uri/")) {
return if (location.search) {
const currentETag = (
document.querySelector("meta[name=gomuks-frontend-etag]") as HTMLMetaElement
)?.content
const newURL = new URL(location.href)
const updateTo = newURL.searchParams.get("updateTo")
if (updateTo === currentETag) {
console.info("Update to etag", updateTo, "successful")
} else {
console.warn("Update to etag", updateTo, "failed, got", currentETag)
} }
const state = JSON.parse(newURL.searchParams.get("state") || "{}")
newURL.search = ""
history.replaceState(state, "", newURL.toString())
return state
}
return history.state
}
const decodedURI = decodeURIComponent(location.hash.slice("#/uri/".length)) const decodedURI = decodeURIComponent(location.hash.slice("#/uri/".length))
const uri = parseMatrixURI(decodedURI) const uri = parseMatrixURI(decodedURI)
if (!uri) { if (!uri) {
console.error("Invalid matrix URI", decodedURI) console.error("Invalid matrix URI", decodedURI)
return return history.state
} }
console.log("Handling URI", uri) console.log("Handling URI", uri)
const newURL = new URL(location.href) const newURL = new URL(location.href)
newURL.hash = "" newURL.hash = ""
newURL.search = ""
if (uri.identifier.startsWith("@")) { if (uri.identifier.startsWith("@")) {
const right_panel = { const right_panel = {
type: "user", type: "user",
@ -184,6 +202,7 @@ const handleURLHash = (client: Client, context: ContextFields) => {
} else { } else {
console.error("Invalid matrix URI", uri) console.error("Invalid matrix URI", uri)
} }
return null
} }
const MainScreen = () => { const MainScreen = () => {
@ -208,8 +227,8 @@ const MainScreen = () => {
} }
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
const initHandle = () => { const initHandle = () => {
listener({ state: history.state } as PopStateEvent) const state = handleURLHash(client, context)
handleURLHash(client, context) listener({ state } as PopStateEvent)
} }
let cancel = () => {} let cancel = () => {}
if (client.initComplete.current) { if (client.initComplete.current) {