Embed version number, force client refresh on mismatch

zikaeroh created

Change summary

Dockerfile                        |  13 +
frontend/src/common/index.ts      |   5 
frontend/src/components/about.tsx |   5 
frontend/src/metadata.json        |   3 
frontend/src/pages/game.tsx       |  15 +
frontend/src/pages/login.tsx      |  30 +++
internal/version/version.go       |   5 
main.go                           | 227 ++++++++++++++++++++------------
8 files changed, 209 insertions(+), 94 deletions(-)

Detailed changes

Dockerfile 🔗

@@ -1,8 +1,19 @@
 FROM node:14 AS JS_BUILD
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends jq moreutils && \
+    rm -rf /var/lib/apt/lists/*
+
 WORKDIR /frontend
 COPY ./frontend/package.json ./frontend/yarn.lock ./
 RUN yarn install --frozen-lockfile
 COPY ./frontend ./
+
+# If using environment variables (REACT_APP_*) to pass data in at build-time,
+# sometimes the values end up in the library code bundles. This means that changing
+# the verison (as below) would invalidate everyone's caches even if the actual code
+# didn't change. To avoid this, resort to using a JSON file that is edited before build.
+ARG version
+RUN jq ".version = \"${version}\"" ./src/metadata.json | sponge ./src/metadata.json
 RUN yarn build
 
 FROM golang:1.14 as GO_BUILD
@@ -21,5 +32,5 @@ FROM gcr.io/distroless/base:nonroot
 WORKDIR /codies
 COPY --from=GO_BUILD /codies/codies ./codies
 COPY --from=JS_BUILD /frontend/build ./frontend/build
-ENTRYPOINT [ "/codies/codies" ]
+ENTRYPOINT [ "/codies/codies", "--prod" ]
 EXPOSE 5000

frontend/src/common/index.ts 🔗

@@ -30,3 +30,8 @@ export function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
 }
 
 export const nameofFactory = <T>() => (name: keyof T) => name;
+
+export function reloadOutdatedPage() {
+    console.log('Frontend version appears to be outdated; reloading to allow the browser to update.');
+    window.location.reload(true);
+}

frontend/src/components/about.tsx 🔗

@@ -13,6 +13,8 @@ import {
 import { Help } from '@material-ui/icons';
 import * as React from 'react';
 
+import { version } from '../metadata.json';
+
 const useStyles = makeStyles((theme: Theme) =>
     createStyles({
         modal: {
@@ -80,7 +82,8 @@ export const AboutButton = (props: { style?: React.CSSProperties }) => {
                         </p>
                         <p>
                             You can find this site&apos;s code on{' '}
-                            <NewPageLink href="https://github.com/zikaeroh/codies">GitHub</NewPageLink>.
+                            <NewPageLink href="https://github.com/zikaeroh/codies">GitHub</NewPageLink>. This site is
+                            currently running version {version}.
                         </p>
                     </Paper>
                 </Fade>

frontend/src/pages/game.tsx 🔗

@@ -3,8 +3,9 @@ import * as React from 'react';
 import useWebSocket from 'react-use-websocket';
 import { v4 } from 'uuid';
 
-import { assertIsDefined, assertNever, noop, websocketUrl } from '../common';
+import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common';
 import { useServerTime } from '../hooks/useServerTime';
+import { version } from '../metadata.json';
 import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
 import { GameView, Sender } from './gameView';
 import { Loading } from './loading';
@@ -65,12 +66,22 @@ function useWS(roomID: string, playerID: string, nickname: string, dead: () => v
     const retry = React.useRef(0);
 
     return useWebSocket(socketUrl, {
-        queryParams: { roomID, playerID, nickname },
+        // The names here matter; explicitly naming them so that renaming
+        // these variables doesn't change the actual wire names.
+        //
+        // X-CODIES-VERSION would be cleaner, but the WS hook doesn't
+        // support anything but query params.
+        queryParams: { roomID: roomID, playerID: playerID, nickname: nickname, codiesVersion: version },
         reconnectAttempts,
         onMessage: () => {
             retry.current = 0;
         },
         onOpen,
+        onClose: (e: CloseEvent) => {
+            if (e.code === 4418) {
+                reloadOutdatedPage();
+            }
+        },
         shouldReconnect: () => {
             if (didUnmount.current) {
                 return false;

frontend/src/pages/login.tsx 🔗

@@ -3,10 +3,19 @@ import isArray from 'lodash/isArray';
 import querystring from 'querystring';
 import * as React from 'react';
 
-import { assertIsDefined, isDefined } from '../common';
+import { assertIsDefined, isDefined, reloadOutdatedPage } from '../common';
 import { LoginForm, LoginFormData } from '../components/loginForm';
+import { version } from '../metadata.json';
 import { RoomResponse } from '../protocol';
 
+function checkOutdated(response: Response) {
+    if (response.status === 418) {
+        reloadOutdatedPage();
+        return true;
+    }
+    return false;
+}
+
 export interface LoginProps {
     onLogin: (roomID: string, nickname: string) => void;
 }
@@ -69,12 +78,22 @@ export const Login = (props: LoginProps) => {
                     onSubmit={async (d: LoginFormData) => {
                         let id = roomID;
 
+                        const headers = {
+                            'X-CODIES-VERSION': version,
+                        };
+
                         if (id) {
                             const query = querystring.stringify({
                                 roomID: id,
                             });
-                            const response = await fetch('/api/exists?' + query);
+                            const response = await fetch('/api/exists?' + query, { headers });
+
+                            if (checkOutdated(response)) {
+                                return;
+                            }
+
                             await response.text();
+
                             if (!response.ok) {
                                 setErrorMessage('Room does not exist.');
                                 setRoomID(undefined);
@@ -90,7 +109,12 @@ export const Login = (props: LoginProps) => {
                                     roomPass: d.roomPass,
                                     create: d.create,
                                 });
-                                response = await fetch('/api/room', { method: 'POST', body: reqBody });
+                                response = await fetch('/api/room', { method: 'POST', body: reqBody, headers });
+
+                                if (checkOutdated(response)) {
+                                    return;
+                                }
+
                                 const body = await response.json();
                                 resp = RoomResponse.parse(body);
                                 // eslint-disable-next-line no-empty

internal/version/version.go 🔗

@@ -10,3 +10,8 @@ func Version() string {
 	}
 	return version
 }
+
+// VersionSet returns true if the verison has been set.
+func VersionSet() bool {
+	return version != ""
+}

main.go 🔗

@@ -3,6 +3,7 @@ package main
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"log"
 	"math/rand"
 	"net/http"
@@ -26,11 +27,14 @@ import (
 var args = struct {
 	Addr    string   `long:"addr" env:"CODIES_ADDR" description:"Address to listen at"`
 	Origins []string `long:"origins" env:"CODIES_ORIGINS" env-delim:"," description:"Additional valid origins for WebSocket connections"`
+	Prod    bool     `long:"prod" env:"CODIES_PROD" description:"Enables production mode"`
 	Debug   bool     `long:"debug" env:"CODIES_DEBUG" description:"Enables debug mode"`
 }{
 	Addr: ":5000",
 }
 
+var wsOpts *websocket.AcceptOptions
+
 func main() {
 	rand.Seed(time.Now().Unix())
 	log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -40,15 +44,25 @@ func main() {
 		os.Exit(1)
 	}
 
+	if !args.Prod && !args.Debug {
+		log.Fatal("missing required option --prod or --debug")
+	} else if args.Prod && args.Debug {
+		log.Fatal("must specify either --prod or --debug")
+	}
+
 	log.Printf("starting codies server, version %s", version.Version())
 
-	wsOpts := &websocket.AcceptOptions{
+	wsOpts = &websocket.AcceptOptions{
 		OriginPatterns: args.Origins,
 	}
 
 	if args.Debug {
 		log.Println("starting in debug mode, allowing any WebSocket origin host")
 		wsOpts.OriginPatterns = []string{"*"}
+	} else {
+		if !version.VersionSet() {
+			log.Fatal("running production build without version set")
+		}
 	}
 
 	g, ctx := errgroup.WithContext(ctxutil.Interrupt())
@@ -68,111 +82,117 @@ func main() {
 			_ = json.NewEncoder(w).Encode(&protocol.TimeResponse{Time: time.Now()})
 		})
 
-		r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) {
-			query := &protocol.ExistsQuery{}
-			if err := queryparam.Parse(r.URL.Query(), query); err != nil {
-				httpErr(w, http.StatusBadRequest)
-				return
-			}
-
-			room := srv.FindRoomByID(query.RoomID)
-			if room == nil {
-				w.WriteHeader(http.StatusNotFound)
-			} else {
-				w.WriteHeader(http.StatusOK)
-			}
+		r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
+			rooms, clients := srv.Stats()
 
-			_, _ = w.Write([]byte("."))
+			enc := json.NewEncoder(w)
+			enc.SetIndent("", "    ")
+			_ = enc.Encode(&protocol.StatsResponse{
+				Rooms:   rooms,
+				Clients: clients,
+			})
 		})
 
-		r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) {
-			defer r.Body.Close()
-
-			req := &protocol.RoomRequest{}
-			if err := json.NewDecoder(r.Body).Decode(req); err != nil {
-				httpErr(w, http.StatusBadRequest)
-				return
-			}
-
-			if !req.Valid() {
-				httpErr(w, http.StatusBadRequest)
-				return
+		r.Group(func(r chi.Router) {
+			if !args.Debug {
+				r.Use(checkVersion)
 			}
 
-			resp := &protocol.RoomResponse{}
-
-			w.Header().Add("Content-Type", "application/json")
-
-			if req.Create {
-				room, err := srv.CreateRoom(req.RoomName, req.RoomPass)
-				if err != nil {
-					switch err {
-					case server.ErrRoomExists:
-						resp.Error = stringPtr("Room already exists.")
-						w.WriteHeader(http.StatusBadRequest)
-					case server.ErrTooManyRooms:
-						resp.Error = stringPtr("Too many rooms.")
-						w.WriteHeader(http.StatusServiceUnavailable)
-					default:
-						resp.Error = stringPtr("An unknown error occurred.")
-						w.WriteHeader(http.StatusInternalServerError)
-					}
-				} else {
-					resp.ID = &room.ID
-					w.WriteHeader(http.StatusOK)
+			r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) {
+				query := &protocol.ExistsQuery{}
+				if err := queryparam.Parse(r.URL.Query(), query); err != nil {
+					httpErr(w, http.StatusBadRequest)
+					return
 				}
-			} else {
-				room := srv.FindRoom(req.RoomName)
-				if room == nil || room.Password != req.RoomPass {
-					resp.Error = stringPtr("Room not found or password does not match.")
+
+				room := srv.FindRoomByID(query.RoomID)
+				if room == nil {
 					w.WriteHeader(http.StatusNotFound)
 				} else {
-					resp.ID = &room.ID
 					w.WriteHeader(http.StatusOK)
 				}
-			}
 
-			_ = json.NewEncoder(w).Encode(resp)
-		})
+				_, _ = w.Write([]byte("."))
+			})
 
-		r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) {
-			query := &protocol.WSQuery{}
-			if err := queryparam.Parse(r.URL.Query(), query); err != nil {
-				httpErr(w, http.StatusBadRequest)
-				return
-			}
+			r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) {
+				defer r.Body.Close()
 
-			if !query.Valid() {
-				httpErr(w, http.StatusBadRequest)
-				return
-			}
+				req := &protocol.RoomRequest{}
+				if err := json.NewDecoder(r.Body).Decode(req); err != nil {
+					httpErr(w, http.StatusBadRequest)
+					return
+				}
 
-			room := srv.FindRoomByID(query.RoomID)
-			if room == nil {
-				httpErr(w, http.StatusNotFound)
-				return
-			}
+				if !req.Valid() {
+					httpErr(w, http.StatusBadRequest)
+					return
+				}
 
-			c, err := websocket.Accept(w, r, wsOpts)
-			if err != nil {
-				log.Println(err)
-				return
-			}
+				resp := &protocol.RoomResponse{}
+
+				w.Header().Add("Content-Type", "application/json")
+
+				if req.Create {
+					room, err := srv.CreateRoom(req.RoomName, req.RoomPass)
+					if err != nil {
+						switch err {
+						case server.ErrRoomExists:
+							resp.Error = stringPtr("Room already exists.")
+							w.WriteHeader(http.StatusBadRequest)
+						case server.ErrTooManyRooms:
+							resp.Error = stringPtr("Too many rooms.")
+							w.WriteHeader(http.StatusServiceUnavailable)
+						default:
+							resp.Error = stringPtr("An unknown error occurred.")
+							w.WriteHeader(http.StatusInternalServerError)
+						}
+					} else {
+						resp.ID = &room.ID
+						w.WriteHeader(http.StatusOK)
+					}
+				} else {
+					room := srv.FindRoom(req.RoomName)
+					if room == nil || room.Password != req.RoomPass {
+						resp.Error = stringPtr("Room not found or password does not match.")
+						w.WriteHeader(http.StatusNotFound)
+					} else {
+						resp.ID = &room.ID
+						w.WriteHeader(http.StatusOK)
+					}
+				}
 
-			g.Go(func() error {
-				room.HandleConn(query.PlayerID, query.Nickname, c)
-				return nil
+				_ = json.NewEncoder(w).Encode(resp)
 			})
-		})
 
-		r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
-			rooms, clients := srv.Stats()
+			r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) {
+				query := &protocol.WSQuery{}
+				if err := queryparam.Parse(r.URL.Query(), query); err != nil {
+					httpErr(w, http.StatusBadRequest)
+					return
+				}
 
-			enc := json.NewEncoder(w)
-			enc.SetIndent("", "    ")
-			_ = enc.Encode(&protocol.StatsResponse{
-				Rooms:   rooms,
-				Clients: clients,
+				if !query.Valid() {
+					httpErr(w, http.StatusBadRequest)
+					return
+				}
+
+				room := srv.FindRoomByID(query.RoomID)
+				if room == nil {
+					httpErr(w, http.StatusNotFound)
+					return
+				}
+
+				c, err := websocket.Accept(w, r, wsOpts)
+				if err != nil {
+					log.Println(err)
+					return
+				}
+
+				g.Go(func() error {
+					room.HandleConn(query.PlayerID, query.Nickname, c)
+					return nil
+				})
 			})
 		})
 	})
@@ -217,6 +237,39 @@ func staticRouter() http.Handler {
 	return r
 }
 
+func checkVersion(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		want := version.Version()
+
+		toCheck := []string{
+			r.Header.Get("X-CODIES-VERSION"),
+			r.URL.Query().Get("codiesVersion"),
+		}
+
+		for _, got := range toCheck {
+			if got == want {
+				next.ServeHTTP(w, r)
+				return
+			}
+		}
+
+		reason := fmt.Sprintf("client version too old, please reload to get %s", want)
+
+		if r.Header.Get("Upgrade") == "websocket" {
+			c, err := websocket.Accept(w, r, wsOpts)
+			if err != nil {
+				log.Println(err)
+				return
+			}
+			c.Close(4418, reason)
+			return
+		}
+
+		w.WriteHeader(http.StatusTeapot)
+		fmt.Fprint(w, reason)
+	})
+}
+
 func httpErr(w http.ResponseWriter, code int) {
 	http.Error(w, http.StatusText(code), code)
 }