Compare commits
2 Commits
54b9112546
...
ab29ec20be
Author | SHA1 | Date |
---|---|---|
Mari | ab29ec20be | 3 years ago |
Mari | 34e64c7130 | 3 years ago |
@ -0,0 +1,27 @@ |
||||
export function normalizeColor(hex: string): string { |
||||
hex = hex.toUpperCase() |
||||
if (hex.length === 4) { |
||||
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}FF` |
||||
} |
||||
if (hex.length === 5) { |
||||
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}` |
||||
} |
||||
if (hex.length === 7) { |
||||
return `${hex}FF` |
||||
} |
||||
return hex |
||||
} |
||||
|
||||
export function packHexColor(color: string): number { |
||||
return parseInt(normalizeColor(color).substring(1), 16) |
||||
} |
||||
|
||||
export function unpackHexColor(color: number): string { |
||||
if (color > 2**32 || Math.floor(color) !== color) { |
||||
throw Error("Packed color was too large or not an integer") |
||||
} |
||||
// this is 1 << 32, so it will produce a single hex digit above the others, even if the
|
||||
// R/G/B is 0 - which we can then trim off and replace with our #
|
||||
// a neat insight from https://stackoverflow.com/a/13397771
|
||||
return "#" + ((color + 0x100000000).toString(16).substring(1)) |
||||
} |
@ -1,6 +1,38 @@ |
||||
import {ClientCommand} from "../actions/ClientAction"; |
||||
import {ClientCommandPB} from "../proto/client"; |
||||
import {CLIENT_ACT, CLIENT_HELLO, CLIENT_REFRESH, ClientCommand, SentAction} from "../actions/ClientAction"; |
||||
import {ClientActPB_IDed, ClientCommandPB} from "../proto/client"; |
||||
import {sendableActionToPB} from "./SyncableActionToPb"; |
||||
|
||||
export function clientToPb(message: ClientCommand): ClientCommandPB { |
||||
export function sentActionToPb(message: SentAction): ClientActPB_IDed { |
||||
return { |
||||
id: message.id, |
||||
action: sendableActionToPB(message.action), |
||||
} |
||||
} |
||||
|
||||
export function clientToPb(message: ClientCommand): ClientCommandPB { |
||||
switch (message.type) { |
||||
case CLIENT_HELLO: |
||||
return { |
||||
hello: { |
||||
version: message.version, |
||||
}, |
||||
refresh: undefined, |
||||
act: undefined, |
||||
} |
||||
case CLIENT_REFRESH: |
||||
return { |
||||
refresh: { |
||||
}, |
||||
hello: undefined, |
||||
act: undefined, |
||||
} |
||||
case CLIENT_ACT: |
||||
return { |
||||
act: { |
||||
actions: message.actions.map(sentActionToPb) |
||||
}, |
||||
hello: undefined, |
||||
refresh: undefined, |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,91 @@ |
||||
import {SyncableStatePB} from "../proto/state"; |
||||
import {SyncedState} from "../state/SyncedState"; |
||||
import {HexagonOrientation, HexCell, HexLayout, HexMap, LineParity} from "../state/HexMap"; |
||||
import {UserStatePB} from "../proto/user"; |
||||
import { |
||||
HexCellPB, |
||||
HexMapPB, |
||||
HexMapPB_Layout, |
||||
HexMapPB_Layout_LineParity, |
||||
HexMapPB_Layout_Orientation |
||||
} from "../proto/map"; |
||||
import {UserState} from "../state/UserState"; |
||||
import {unpackHexColor} from "../util/ColorUtils"; |
||||
import {encode} from "base64-arraybuffer"; |
||||
import {StorageCoordinatesPB} from "../proto/coords"; |
||||
import {StorageCoordinates} from "../state/Coordinates"; |
||||
|
||||
export function storageCoordsFromPb(coords: StorageCoordinatesPB): StorageCoordinates { |
||||
return { |
||||
line: coords.line, |
||||
cell: coords.cell, |
||||
}; |
||||
} |
||||
|
||||
function orientationFromPb(orientation: HexMapPB_Layout_Orientation): HexagonOrientation { |
||||
switch (orientation) { |
||||
case HexMapPB_Layout_Orientation.FLAT_TOP: |
||||
return HexagonOrientation.FLAT_TOP |
||||
case HexMapPB_Layout_Orientation.POINTY_TOP: |
||||
return HexagonOrientation.POINTY_TOP |
||||
case HexMapPB_Layout_Orientation.UNKNOWN_ORIENTATION: |
||||
case HexMapPB_Layout_Orientation.UNRECOGNIZED: |
||||
throw Error("unknown orientation") |
||||
} |
||||
} |
||||
|
||||
function lineParityFromPb(parity: HexMapPB_Layout_LineParity): LineParity { |
||||
switch (parity) { |
||||
case HexMapPB_Layout_LineParity.EVEN: |
||||
return LineParity.EVEN |
||||
case HexMapPB_Layout_LineParity.ODD: |
||||
return LineParity.ODD |
||||
case HexMapPB_Layout_LineParity.UNKNOWN_LINE: |
||||
case HexMapPB_Layout_LineParity.UNRECOGNIZED: |
||||
throw Error("unknown line parity") |
||||
} |
||||
} |
||||
|
||||
function layoutFromPb(layout: HexMapPB_Layout): HexLayout { |
||||
return { |
||||
orientation: orientationFromPb(layout.orientation), |
||||
indentedLines: lineParityFromPb(layout.indentedLines), |
||||
} |
||||
} |
||||
|
||||
function cellFromPb(cell: HexCellPB): HexCell { |
||||
return { |
||||
color: unpackHexColor(cell.color) |
||||
} |
||||
} |
||||
|
||||
function mapFromPb(map: HexMapPB): HexMap { |
||||
if (!map.layout || !map.layer) { |
||||
throw Error("HexMapPB did not have layout and layer"); |
||||
} |
||||
return { |
||||
xid: encode(map.xid), |
||||
lines: map.lines, |
||||
cellsPerLine: map.cellsPerLine, |
||||
layout: layoutFromPb(map.layout), |
||||
layer: map.layer.lines.map((line): HexCell[] => { |
||||
return line.cells.map(cellFromPb) |
||||
}), |
||||
}; |
||||
} |
||||
|
||||
function userFromPb(user: UserStatePB): UserState { |
||||
return { |
||||
activeColor: unpackHexColor(user.color) |
||||
} |
||||
} |
||||
|
||||
export function stateFromPb(state: SyncableStatePB): SyncedState { |
||||
if (!state.map || !state.user) { |
||||
throw Error("SyncableStatePB did not have map and user") |
||||
} |
||||
return { |
||||
map: mapFromPb(state.map), |
||||
user: userFromPb(state.user), |
||||
} |
||||
} |
@ -0,0 +1,9 @@ |
||||
import {StorageCoordinates} from "../state/Coordinates"; |
||||
import {StorageCoordinatesPB} from "../proto/coords"; |
||||
|
||||
export function storageCoordsToPb(storageCoords: StorageCoordinates): StorageCoordinatesPB { |
||||
return { |
||||
line: storageCoords.line, |
||||
cell: storageCoords.cell, |
||||
} |
||||
} |
@ -0,0 +1,50 @@ |
||||
import {ServerCommandPB} from "../proto/server"; |
||||
import { |
||||
SERVER_ACT, |
||||
SERVER_FAILED, |
||||
SERVER_HELLO, |
||||
SERVER_OK, |
||||
SERVER_REFRESH, |
||||
ServerCommand |
||||
} from "../actions/ServerAction"; |
||||
import {stateFromPb} from "./MapFromPb"; |
||||
import {syncableActionFromPb} from "./SyncableActionFromPb"; |
||||
|
||||
export function serverFromPb(pb: ServerCommandPB): ServerCommand { |
||||
if (pb.hello) { |
||||
if (!pb.hello.state) { |
||||
throw Error("No state for Server Hello") |
||||
} |
||||
return { |
||||
type: SERVER_HELLO, |
||||
version:pb.hello.version, |
||||
state: stateFromPb(pb.hello.state), |
||||
} |
||||
} else if (pb.refresh) { |
||||
if (!pb.refresh.state) { |
||||
throw Error("No state for Server Refresh") |
||||
} |
||||
return { |
||||
type: SERVER_REFRESH, |
||||
state: stateFromPb(pb.refresh.state), |
||||
} |
||||
} else if (pb.ok) { |
||||
return { |
||||
type: SERVER_OK, |
||||
ids: pb.ok.ids, |
||||
} |
||||
} else if (pb.failed) { |
||||
return { |
||||
type: SERVER_FAILED, |
||||
ids: pb.failed.ids, |
||||
error: pb.failed.error, |
||||
} |
||||
} else if (pb.act) { |
||||
return { |
||||
type: SERVER_ACT, |
||||
actions: pb.act.actions.map(syncableActionFromPb) |
||||
} |
||||
} else { |
||||
throw Error("No actual commands set on command") |
||||
} |
||||
} |
@ -0,0 +1,35 @@ |
||||
import {ClientActionPB, ServerActionPB} from "../proto/action"; |
||||
import {SyncableAction} from "../actions/ServerAction"; |
||||
import {SendableAction} from "../actions/ClientAction"; |
||||
import {unpackHexColor} from "../util/ColorUtils"; |
||||
import {USER_ACTIVE_COLOR} from "../actions/UserAction"; |
||||
import {CELL_COLOR} from "../actions/CellAction"; |
||||
import {storageCoordsFromPb} from "./MapFromPb"; |
||||
|
||||
function sendableActionFromPB(action: ClientActionPB): SendableAction { |
||||
if (action.cellSetColor) { |
||||
if (!action.cellSetColor.at) { |
||||
throw Error("No location set in cellSetColor") |
||||
} |
||||
return { |
||||
type: CELL_COLOR, |
||||
at: storageCoordsFromPb(action.cellSetColor.at), |
||||
color: unpackHexColor(action.cellSetColor.color), |
||||
} |
||||
} else if (action.userSetActiveColor) { |
||||
return { |
||||
type: USER_ACTIVE_COLOR, |
||||
color: unpackHexColor(action.userSetActiveColor.color) |
||||
} |
||||
} else { |
||||
throw Error("No action set in ClientAction") |
||||
} |
||||
} |
||||
|
||||
export function syncableActionFromPb(action: ServerActionPB): SyncableAction { |
||||
if (action.client) { |
||||
return sendableActionFromPB(action.client) |
||||
} else { |
||||
throw Error("No action set in ServerAction") |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
import {ClientActionPB} from "../proto/action"; |
||||
import {SendableAction} from "../actions/ClientAction"; |
||||
import {packHexColor} from "../util/ColorUtils"; |
||||
import {storageCoordsToPb} from "./MapToPb"; |
||||
import {USER_ACTIVE_COLOR} from "../actions/UserAction"; |
||||
import {CELL_COLOR} from "../actions/CellAction"; |
||||
|
||||
export function sendableActionToPB(action: SendableAction): ClientActionPB { |
||||
switch (action.type) { |
||||
case CELL_COLOR: |
||||
return { |
||||
cellSetColor: { |
||||
at: storageCoordsToPb(action.at), |
||||
color: packHexColor(action.color), |
||||
}, |
||||
userSetActiveColor: undefined, |
||||
} |
||||
case USER_ACTIVE_COLOR: |
||||
return { |
||||
userSetActiveColor: { |
||||
color: packHexColor(action.color) |
||||
}, |
||||
cellSetColor: undefined, |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,72 @@ |
||||
import {ReactElement, useContext, useEffect, useRef, useState} from "react"; |
||||
import { |
||||
CLIENT_ACT, |
||||
CLIENT_REFRESH, |
||||
ClientActCommand, |
||||
ClientGoodbyeAction, |
||||
ClientHelloCommand, |
||||
ClientRefreshCommand, isClientGoodbyeCommand, |
||||
isClientHelloCommand, |
||||
isClientRefreshCommand, |
||||
SendableAction, |
||||
SentAction |
||||
} from "../actions/ClientAction"; |
||||
import {DispatchContext} from "../ui/context/DispatchContext"; |
||||
import {WebsocketTranslator} from "./WebsocketTranslator"; |
||||
import {ServerConnectionState} from "../state/NetworkState"; |
||||
import {SERVER_SOCKET_STARTUP, SocketState} from "../actions/ServerAction"; |
||||
|
||||
export function WebsocketReactAdapter({url, protocols = ["v1.hexmap.deliciousreya.net"], state, specialMessage, pendingMessages, nextID}: { |
||||
url: string, |
||||
protocols?: readonly string[], |
||||
state: ServerConnectionState, |
||||
specialMessage: ClientHelloCommand | ClientRefreshCommand | ClientGoodbyeAction | null, |
||||
pendingMessages: readonly SendableAction[], |
||||
nextID: number |
||||
}): ReactElement { |
||||
const dispatch = useContext(DispatchContext) |
||||
if (dispatch === null) { |
||||
throw Error("What the heck?! No dispatch?! I quit!") |
||||
} |
||||
const connector = useRef(new WebsocketTranslator({ |
||||
url, |
||||
protocols, |
||||
onStartup: dispatch, |
||||
onMessage: dispatch, |
||||
onError: dispatch, |
||||
onGoodbye: dispatch, |
||||
})) |
||||
useEffect(() => { |
||||
connector.current.connect(); |
||||
dispatch({ |
||||
type: SERVER_SOCKET_STARTUP, |
||||
state: SocketState.CONNECTING, |
||||
}); |
||||
}, [dispatch]) |
||||
const [lastSpecialMessage, setLastSpecialMessage] = useState<ClientHelloCommand | ClientRefreshCommand | ClientGoodbyeAction | null>(null) |
||||
useEffect(() => { |
||||
if (state === ServerConnectionState.CONNECTED && pendingMessages.length > 0) { |
||||
const sentMessages: SentAction[] = pendingMessages.map((action, index) => { |
||||
return {id: index + nextID, action} |
||||
}); |
||||
const sentMessage: ClientActCommand = { |
||||
type: CLIENT_ACT, |
||||
actions: sentMessages, |
||||
}; |
||||
connector.current.send(sentMessage) |
||||
dispatch(sentMessage) |
||||
} |
||||
}, [nextID, dispatch, pendingMessages, state]) |
||||
useEffect(() => { |
||||
if (specialMessage !== null && specialMessage !== lastSpecialMessage) { |
||||
if ((state === ServerConnectionState.AWAITING_HELLO && isClientHelloCommand(specialMessage)) |
||||
|| (state === ServerConnectionState.AWAITING_REFRESH && isClientRefreshCommand(specialMessage))) { |
||||
connector.current.send(specialMessage); |
||||
} else if (state === ServerConnectionState.AWAITING_GOODBYE && isClientGoodbyeCommand(specialMessage)) { |
||||
connector.current.close(specialMessage.code, specialMessage.reason) |
||||
} |
||||
setLastSpecialMessage(specialMessage); |
||||
} |
||||
}, [specialMessage, lastSpecialMessage, setLastSpecialMessage, state]) |
||||
return <div className="connectionState" onClick={() => dispatch ? dispatch({type: CLIENT_REFRESH}) : null}>{state}</div> |
||||
} |
@ -0,0 +1,193 @@ |
||||
package main |
||||
|
||||
import ( |
||||
"context" |
||||
"git.reya.zone/reya/hexmap/server/room" |
||||
"git.reya.zone/reya/hexmap/server/state" |
||||
"git.reya.zone/reya/hexmap/server/ws" |
||||
"github.com/gorilla/websocket" |
||||
"go.uber.org/zap" |
||||
"google.golang.org/protobuf/proto" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"os" |
||||
"path/filepath" |
||||
"strconv" |
||||
"time" |
||||
) |
||||
|
||||
const SaveDir = "/home/reya/hexmaps" |
||||
|
||||
func save(m state.HexMap, l *zap.Logger) error { |
||||
filename := filepath.Join(SaveDir, "map."+strconv.FormatInt(time.Now().Unix(), 16)) |
||||
l.Debug("Saving to file", zap.String("filename", filename)) |
||||
marshaled, err := proto.Marshal(m.ToPB()) |
||||
l.Debug("Marshaled proto") |
||||
if err != nil { |
||||
return err |
||||
} |
||||
l.Debug("Opening file") |
||||
file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0x644) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
l.Debug("Writing to file") |
||||
_, err = file.Write(marshaled) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
l.Debug("Closing file") |
||||
err = file.Close() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
l.Info("Saved to file", zap.String("filename", filename)) |
||||
return nil |
||||
} |
||||
|
||||
func load(l *zap.Logger) (*state.HexMap, error) { |
||||
filename := filepath.Join(SaveDir, "map.LOAD") |
||||
l.Debug("Loading from file", zap.String("filename", filename)) |
||||
file, err := os.Open(filename) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
l.Debug("Reading file") |
||||
marshaled, err := ioutil.ReadAll(file) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
pb := &state.HexMapPB{} |
||||
l.Debug("Extracting protobuf from file") |
||||
err = proto.Unmarshal(marshaled, pb) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
l.Debug("Closing file") |
||||
err = file.Close() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
m, err := pb.ToGo() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
l.Info("Loaded from file", zap.String("filename", filename)) |
||||
return &m, nil |
||||
} |
||||
|
||||
func BackupMap(client *room.Client, l *zap.Logger) { |
||||
var err error |
||||
myState := &state.Synced{} |
||||
l.Info("Starting backup system") |
||||
for { |
||||
msg := <-client.IncomingChannel() |
||||
switch typedMsg := msg.(type) { |
||||
case *room.JoinResponse: |
||||
myState = typedMsg.CurrentState() |
||||
err := save(myState.Map, l) |
||||
if err != nil { |
||||
l.Error("Failed saving during join response", zap.Error(err)) |
||||
} |
||||
case *room.ActionBroadcast: |
||||
err = typedMsg.Action().Apply(myState) |
||||
if err == nil { |
||||
err = save(myState.Map, l) |
||||
if err != nil { |
||||
l.Error("Failed saving during action broadcast", zap.Error(err)) |
||||
} |
||||
} |
||||
case *room.ShutdownRequest: |
||||
client.OutgoingChannel() <- client.AcknowledgeShutdown() |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func ServeWS(logger *zap.Logger) (err error) { |
||||
m := http.NewServeMux() |
||||
httpLogger := logger.Named("HTTP") |
||||
hexes, err := load(logger) |
||||
if err != nil { |
||||
hexes = state.NewHexMap(state.Layout{ |
||||
Orientation: state.PointyTop, |
||||
IndentedLines: state.EvenLines, |
||||
}, 25, 10) |
||||
} |
||||
rm := room.New(room.NewOptions{ |
||||
BaseLogger: logger.Named("Room"), |
||||
StartingState: &state.Synced{ |
||||
Map: *hexes, |
||||
User: state.UserState{ |
||||
ActiveColor: state.Color{ |
||||
R: 0, |
||||
G: 0, |
||||
B: 0, |
||||
A: 255, |
||||
}, |
||||
}, |
||||
}, |
||||
StartingClientOptions: room.NewClientOptions{ |
||||
IncomingChannel: nil, |
||||
AcceptBroadcasts: true, |
||||
RequestStartingState: true, |
||||
}, |
||||
}) |
||||
go BackupMap(rm, logger.Named("BackupMap")) |
||||
m.Handle("/map", &ws.HTTPHandler{ |
||||
Upgrader: websocket.Upgrader{ |
||||
Subprotocols: []string{"v1.hexmap.deliciousreya.net"}, |
||||
CheckOrigin: func(r *http.Request) bool { |
||||
return r.Header.Get("Origin") == "https://hexmap.deliciousreya.net" |
||||
}, |
||||
}, |
||||
Logger: logger.Named("WS"), |
||||
Room: rm, |
||||
}) |
||||
srv := http.Server{ |
||||
Addr: "127.0.0.1:5238", |
||||
Handler: m, |
||||
ErrorLog: zap.NewStdLog(httpLogger), |
||||
} |
||||
m.HandleFunc("/exit", func(writer http.ResponseWriter, request *http.Request) { |
||||
// Some light dissuasion of accidental probing.
|
||||
// To keep good people out.
|
||||
if request.FormValue("superSecretPassword") != "Gesture/Retrial5/Untrained/Countable/Extrude/Jeep/Cheese/Carbon" { |
||||
writer.WriteHeader(403) |
||||
_, err = writer.Write([]byte("... What are you trying to pull?")) |
||||
return |
||||
} |
||||
writer.WriteHeader(200) |
||||
_, err := writer.Write([]byte("OK, shutting down, bye!")) |
||||
if err != nil { |
||||
logger.Warn("Error while writing goodbye response", zap.Error(err)) |
||||
} |
||||
time.AfterFunc(500*time.Millisecond, func() { |
||||
err := srv.Shutdown(context.Background()) |
||||
if err != nil { |
||||
logger.Error("Error while shutting down the server", zap.Error(err)) |
||||
} |
||||
}) |
||||
}) |
||||
err = srv.ListenAndServe() |
||||
if err != nil && err != http.ErrServerClosed { |
||||
return err |
||||
} |
||||
rm.OutgoingChannel() <- rm.Stop() |
||||
for { |
||||
msg := <-rm.IncomingChannel() |
||||
switch msg.(type) { |
||||
case *room.ShutdownRequest: |
||||
rm.OutgoingChannel() <- rm.AcknowledgeShutdown() |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func main() { |
||||
logger, err := zap.NewDevelopment() |
||||
err = ServeWS(logger) |
||||
if err != nil { |
||||
logger.Fatal("Error while serving HTTP", zap.Error(err)) |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
package state |
||||
|
||||
import "errors" |
||||
|
||||
var ErrOneofNotSet = errors.New("no value was given for a oneof") |
@ -1,37 +0,0 @@ |
||||
package websocket |
||||
|
||||
import ( |
||||
"github.com/gorilla/websocket" |
||||
"time" |
||||
) |
||||
|
||||
const ( |
||||
// ReadTimeLimit is the maximum time the server is willing to wait after receiving a message before receiving another one.
|
||||
ReadTimeLimit = 60 * time.Second |
||||
// WriteTimeLimit is the maximum time the server is willing to wait to send a message.
|
||||
WriteTimeLimit = 10 * time.Second |
||||
// ControlTimeLimit is the maximum time the server is willing to wait to send a control message like Ping or Close.
|
||||
ControlTimeLimit = (WriteTimeLimit * 5) / 10 |
||||
// PingDelay is the time between pings.
|
||||
// It must be less than ReadTimeLimit to account for latency and delays on either side.
|
||||
PingDelay = (ReadTimeLimit * 7) / 10 |
||||
) |
||||
|
||||
// A Connection corresponds to a pair of actors.
|
||||
type Connection struct { |
||||
conn *websocket.Conn |
||||
r reader |
||||
w writer |
||||
} |
||||
|
||||
// ReadChannel returns the channel that can be used to read client messages from the connection.
|
||||
// After receiving SocketClosed, the reader will close its channel.
|
||||
func (c *Connection) ReadChannel() <-chan ClientCommand { |
||||
return c.r.channel |
||||
} |
||||
|
||||
// WriteChannel returns the channel that can be used to send server messages on the connection.
|
||||
// After sending SocketClosed, the writer will close its channel; do not send any further messages on the channel.
|
||||
func (c *Connection) WriteChannel() chan<- ServerCommand { |
||||
return c.w.channel |
||||
} |
@ -0,0 +1,276 @@ |
||||
package ws |
||||
|
||||
import ( |
||||
"git.reya.zone/reya/hexmap/server/action" |
||||
"git.reya.zone/reya/hexmap/server/room" |
||||
"github.com/gorilla/websocket" |
||||
"go.uber.org/zap" |
||||
"net/http" |
||||
"time" |
||||
) |
||||
|
||||
const ( |
||||
// ReadTimeLimit is the maximum time the server is willing to wait after receiving a message before receiving another one.
|
||||
ReadTimeLimit = 60 * time.Second |
||||
// WriteTimeLimit is the maximum time the server is willing to wait to send a message.
|
||||
WriteTimeLimit = 10 * time.Second |
||||
// ControlTimeLimit is the maximum time the server is willing to wait to send a control message like Ping or Close.
|
||||
ControlTimeLimit = (WriteTimeLimit * 5) / 10 |
||||
// PingDelay is the time between pings.
|
||||
// It must be less than ReadTimeLimit to account for latency and delays on either side.
|
||||
PingDelay = (ReadTimeLimit * 7) / 10 |
||||
) |
||||
|
||||
type HTTPHandler struct { |
||||
Upgrader websocket.Upgrader |
||||
Logger *zap.Logger |
||||
Room *room.Client |
||||
} |
||||
|
||||
func destroyBadProtocolSocket(c *websocket.Conn, logger *zap.Logger) { |
||||
err := c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseProtocolError, "Invalid subprotocols"), time.Now().Add(ControlTimeLimit)) |
||||
if err != nil { |
||||
logger.Error("Failed to write close message") |
||||
} |
||||
err = c.SetReadDeadline(time.Now().Add(ControlTimeLimit)) |
||||
if err != nil { |
||||
logger.Error("Failed to set read deadline") |
||||
} |
||||
for { |
||||
_, _, err := c.ReadMessage() |
||||
if err != nil { |
||||
if !websocket.IsCloseError(err, websocket.CloseProtocolError) { |
||||
logger.Error("Websocket connection shut down ignominiously", zap.Error(err)) |
||||
} |
||||
return |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (h *HTTPHandler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) { |
||||
c, err := h.Upgrader.Upgrade(responseWriter, request, http.Header{}) |
||||
if err != nil { |
||||
h.Logger.Error("Failed to upgrade ws connection", zap.Error(err)) |
||||
return |
||||
} |
||||
if c.Subprotocol() == "" { |
||||
h.Logger.Error("No matching subprotocol", zap.String("clientProtocols", request.Header.Get("Sec-Websocket-Protocol"))) |
||||
go destroyBadProtocolSocket(c, h.Logger) |
||||
return |
||||
} |
||||
result := NewConnection(c, h.Logger.Named("Connection")) |
||||
exchange(result, h.Logger.Named("Link"), func(o room.NewClientOptions) *room.Client { |
||||
return h.Room.NewClient(o) |
||||
}) |
||||
} |
||||
|
||||
func exchange(c *Connection, l *zap.Logger, clientMaker func(options room.NewClientOptions) *room.Client) { |
||||
wsr := c.ReadChannel() |
||||
wsw := c.WriteChannel() |
||||
|
||||
l.Info("Connection established") |
||||
closeWith := &SocketClosed{ |
||||
Code: websocket.CloseAbnormalClosure, |
||||
Text: "I don't know what happened. But goodbye!", |
||||
} |
||||
defer func() { |
||||
l.Info("Shutting down") |
||||
// Wait for the websocket connection to shut down.
|
||||
wsw <- closeWith |
||||
if wsr != nil { |
||||
for { |
||||
msg := <-wsr |
||||
switch msg.(type) { |
||||
case *SocketClosed: |
||||
return |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
// State 1: Waiting for a hello.
|
||||
// Anything else is death.
|
||||
cmd := <-wsr |
||||
switch typedCmd := cmd.(type) { |
||||
case *ClientHello: |
||||
if typedCmd.Version != ProtocolVersion { |
||||
l.Warn("Bad Hello version") |
||||
// Disgusting. I can't even look at you.
|
||||
closeWith = &SocketClosed{ |
||||
Code: websocket.CloseProtocolError, |
||||
Text: "Wrong protocol version", |
||||
} |
||||
return |
||||
} |
||||
l.Info("Got Hello") |
||||
default: |
||||
l.Warn("Got NON-hello") |
||||
closeWith = &SocketClosed{ |
||||
Code: websocket.CloseProtocolError, |
||||
Text: "You don't even say hello?", |
||||
} |
||||
return |
||||
} |
||||
l.Info("Waiting for room.") |
||||
// State 2: Waiting for the room to notice us.
|
||||
rm := clientMaker(room.NewClientOptions{ |
||||
AcceptBroadcasts: true, |
||||
RequestStartingState: true, |
||||
}) |
||||
rmr := rm.IncomingChannel() |
||||
rmw := rm.OutgoingChannel() |
||||
var leaveWith room.ClientMessage = nil |
||||
defer func() { |
||||
l.Info("Leaving room") |
||||
if leaveWith == nil { |
||||
leaveWith = rm.Leave() |
||||
} |
||||
rmw <- leaveWith |
||||
if _, ok := leaveWith.(*room.ShutdownResponse); ok { |
||||
// The room was already shutting down.
|
||||
return |
||||
} |
||||
for { |
||||
msg := <-rmr |
||||
switch msg.(type) { |
||||
case *room.LeaveResponse: |
||||
return |
||||
case *room.ShutdownRequest: |
||||
rmw <- rm.AcknowledgeShutdown() |
||||
return |
||||
} |
||||
} |
||||
}() |
||||
l.Info("Waiting for JoinResponse") |
||||
msg := <-rmr |
||||
switch typedMsg := msg.(type) { |
||||
case *room.JoinResponse: |
||||
l.Info("Got JoinResponse") |
||||
wsw <- &ServerHello{ |
||||
Version: ProtocolVersion, |
||||
State: typedMsg.CurrentState(), |
||||
} |
||||
case *room.ShutdownRequest: |
||||
l.Info("Got ShutdownRequest") |
||||
// Room was shutting down when we joined, oops!
|
||||
closeWith = &SocketClosed{ |
||||
Code: websocket.CloseGoingAway, |
||||
Text: "Shutting down right as you joined. Sorry!", |
||||
} |
||||
return |
||||
default: |
||||
l.Info("Got non-JoinResponse/ShutdownRequest") |
||||
// Uh. That's concerning. We don't have anything to send our client.
|
||||
// Let's just give up.
|
||||
return |
||||
} |
||||
l.Info("Waiting for messages") |
||||
for { |
||||
select { |
||||
case cmd := <-wsr: |
||||
switch typedCmd := cmd.(type) { |
||||
case *ClientHello: |
||||
l.Info("Got unnecessary ClientHello") |
||||
// Huh???
|
||||
closeWith = &SocketClosed{ |
||||
Code: websocket.CloseProtocolError, |
||||
Text: "Enough hellos. Goodbye.", |
||||
} |
||||
return |
||||
case *ClientRefresh: |
||||
l.Info("Got ClientRefresh") |
||||
rmw <- rm.Refresh() |
||||
case *ClientAct: |
||||
l.Info("Got ClientAct") |
||||
for _, act := range typedCmd.Actions { |
||||
rmw <- rm.Apply(act) |
||||
} |
||||
case *SocketClosed: |
||||
l.Info("Got SocketClosed", zap.Object("close", typedCmd)) |
||||
closeWith = typedCmd |
||||
return |
||||
case *ClientMalformed: |
||||
l.Warn("Got ClientMalformed") |
||||
return |
||||
} |
||||
case msg := <-rmr: |
||||
switch typedMsg := msg.(type) { |
||||
case *room.JoinResponse: |
||||
// Huh????
|
||||
l.Info("Got unnecesary JoinResponse") |
||||
return |
||||
case *room.RefreshResponse: |
||||
l.Info("Got RefreshResponse") |
||||
wsw <- &ServerRefresh{ |
||||
State: typedMsg.CurrentState(), |
||||
} |
||||
case *room.ApplyResponse: |
||||
l.Info("Got ApplyResponse") |
||||
if typedMsg.Success() { |
||||
wsw <- &ServerOK{ |
||||
IDs: []uint32{typedMsg.ActionID()}, |
||||
} |
||||
} else { |
||||
wsw <- &ServerFailed{ |
||||
IDs: []uint32{typedMsg.ActionID()}, |
||||
Error: typedMsg.Failure().Error(), |
||||
} |
||||
} |
||||
case *room.ActionBroadcast: |
||||
l.Info("Got ActionBroadcast") |
||||
wsw <- &ServerAct{ |
||||
Actions: action.ServerSlice{typedMsg.Action()}, |
||||
} |
||||
case *room.LeaveResponse: |
||||
l.Info("Got odd LeaveResponse") |
||||
// Oh. u_u I wasn't- okay.
|
||||
return |
||||
case *room.ShutdownRequest: |
||||
// Oh. Oh! Okay! Sorry!
|
||||
l.Info("Got ShutdownRequest") |
||||
leaveWith = rm.AcknowledgeShutdown() |
||||
return |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// A Connection corresponds to a pair of actors.
|
||||
type Connection struct { |
||||
r reader |
||||
w writer |
||||
} |
||||
|
||||
func NewConnection(conn *websocket.Conn, logger *zap.Logger) *Connection { |
||||
readChan := make(chan time.Time) |
||||
out := &Connection{ |
||||
r: reader{ |
||||
conn: conn, |
||||
channel: make(chan ClientCommand), |
||||
readNotifications: readChan, |
||||
logger: logger.Named("reader"), |
||||
}, |
||||
w: writer{ |
||||
conn: conn, |
||||
channel: make(chan ServerCommand), |
||||
readNotifications: readChan, |
||||
timer: nil, |
||||
nextPingAt: time.Time{}, |
||||
logger: logger.Named("writer"), |
||||
}, |
||||
} |
||||
go out.r.act() |
||||
go out.w.act() |
||||
return out |
||||
} |
||||
|
||||
// ReadChannel returns the channel that can be used to read client messages from the connection.
|
||||
// After receiving SocketClosed, the reader will close its channel.
|
||||
func (c *Connection) ReadChannel() <-chan ClientCommand { |
||||
return c.r.channel |
||||
} |
||||
|
||||
// WriteChannel returns the channel that can be used to send server messages on the connection.
|
||||
// After sending SocketClosed, the writer will close its channel; do not send any further messages on the channel.
|
||||
func (c *Connection) WriteChannel() chan<- ServerCommand { |
||||
return c.w.channel |
||||
} |
@ -1,4 +1,4 @@ |
||||
package websocket |
||||
package ws |
||||
|
||||
import ( |
||||
"git.reya.zone/reya/hexmap/server/action" |
Loading…
Reference in new issue