Embed version number, force client refresh on mismatch
This commit is contained in:
parent
987b6a9dfc
commit
02766918f9
13
Dockerfile
13
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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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'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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"version": "(devel)"
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10,3 +10,8 @@ func Version() string {
|
|||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// VersionSet returns true if the verison has been set.
|
||||
func VersionSet() bool {
|
||||
return version != ""
|
||||
}
|
||||
|
|
249
main.go
249
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,103 +82,6 @@ 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)
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte("."))
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
rooms, clients := srv.Stats()
|
||||
|
||||
|
@ -175,6 +92,109 @@ func main() {
|
|||
Clients: clients,
|
||||
})
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
if !args.Debug {
|
||||
r.Use(checkVersion)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte("."))
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue