DEV 1404: Support streaming CLI games to the browser board (#88)
* add minimal support for serving a game to the board UI * refactor into new board package * support reporting errors and author names to board * support passing an alternate board URL * avoid using IPv6 for local URL * use rules.Point instead of a custom Point type for board package * use zero for generic communication error code in cli * rename createGameEvent to buildFrameEvent * tests for conversion from boardState/snakeState to game frame
This commit is contained in:
parent
f6c3ed0791
commit
a451cda9c4
6 changed files with 504 additions and 22 deletions
72
board/api.go
Normal file
72
board/api.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package board
|
||||
|
||||
import (
|
||||
"github.com/BattlesnakeOfficial/rules"
|
||||
)
|
||||
|
||||
// Types used to implement the JSON API expected by the board client.
|
||||
|
||||
// JSON structure returned by the game status endpoint.
|
||||
type Game struct {
|
||||
ID string `json:"ID"`
|
||||
Status string `json:"Status"`
|
||||
Width int `json:"Width"`
|
||||
Height int `json:"Height"`
|
||||
Ruleset map[string]string `json:"Ruleset"`
|
||||
SnakeTimeout int `json:"SnakeTimeout"`
|
||||
Source string `json:"Source"`
|
||||
RulesetName string `json:"RulesetName"`
|
||||
RulesStages []string `json:"RulesStages"`
|
||||
Map string `json:"Map"`
|
||||
}
|
||||
|
||||
// The websocket stream has support for returning different types of events, along with a "type" attribute.
|
||||
type GameEventType string
|
||||
|
||||
const (
|
||||
EVENT_TYPE_FRAME GameEventType = "frame"
|
||||
EVENT_TYPE_GAME_END GameEventType = "game_end"
|
||||
)
|
||||
|
||||
// Top-level JSON structure sent in each websocket frame.
|
||||
type GameEvent struct {
|
||||
EventType GameEventType `json:"Type"`
|
||||
Data interface{} `json:"Data"`
|
||||
}
|
||||
|
||||
// Represents a single turn in the game.
|
||||
type GameFrame struct {
|
||||
Turn int `json:"Turn"`
|
||||
Snakes []Snake `json:"Snakes"`
|
||||
Food []rules.Point `json:"Food"`
|
||||
Hazards []rules.Point `json:"Hazards"`
|
||||
}
|
||||
|
||||
type GameEnd struct {
|
||||
Game Game `json:"game"`
|
||||
}
|
||||
|
||||
type Snake struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Body []rules.Point `json:"Body"`
|
||||
Health int `json:"Health"`
|
||||
Death *Death `json:"Death"`
|
||||
Color string `json:"Color"`
|
||||
HeadType string `json:"HeadType"`
|
||||
TailType string `json:"TailType"`
|
||||
Latency string `json:"Latency"`
|
||||
Shout string `json:"Shout"`
|
||||
Squad string `json:"Squad"`
|
||||
Author string `json:"Author"`
|
||||
StatusCode int `json:"StatusCode"`
|
||||
Error string `json:"Error"`
|
||||
IsBot bool `json:"IsBot"`
|
||||
IsEnvironment bool `json:"IsEnvironment"`
|
||||
}
|
||||
|
||||
type Death struct {
|
||||
Cause string `json:"Cause"`
|
||||
Turn int `json:"Turn"`
|
||||
EliminatedBy string `json:"EliminatedBy"`
|
||||
}
|
||||
130
board/server.go
Normal file
130
board/server.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package board
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
// A minimal server capable of handling the requests from a single browser client running the board viewer.
|
||||
type BoardServer struct {
|
||||
game Game
|
||||
events chan GameEvent // channel for sending events from the game runner to the browser client
|
||||
done chan bool // channel for signalling (via closing) that all events have been sent to the browser client
|
||||
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
func NewBoardServer(game Game) *BoardServer {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
server := &BoardServer{
|
||||
game: game,
|
||||
events: make(chan GameEvent, 1000), // buffered channel to allow game to run ahead of browser client
|
||||
done: make(chan bool),
|
||||
httpServer: &http.Server{
|
||||
Handler: cors.Default().Handler(mux),
|
||||
},
|
||||
}
|
||||
|
||||
mux.HandleFunc("/games/"+game.ID, server.handleGame)
|
||||
mux.HandleFunc("/games/"+game.ID+"/events", server.handleWebsocket)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// Handle the /games/:id request made by the board to fetch the game metadata.
|
||||
func (server *BoardServer) handleGame(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
err := json.NewEncoder(w).Encode(struct {
|
||||
Game Game
|
||||
}{server.game})
|
||||
if err != nil {
|
||||
log.Printf("Unable to serialize game: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the /games/:id/events websocket request made by the board to receive game events.
|
||||
func (server *BoardServer) handleWebsocket(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Unable to upgrade connection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = ws.Close()
|
||||
if err != nil {
|
||||
log.Printf("Unable to close websocket stream")
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range server.events {
|
||||
jsonStr, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Printf("Unable to serialize event for websocket: %v", err)
|
||||
}
|
||||
|
||||
err = ws.WriteMessage(websocket.TextMessage, jsonStr)
|
||||
if err != nil {
|
||||
log.Printf("Unable to write to websocket: %v", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Finished writing all game events, signalling game server to stop")
|
||||
close(server.done)
|
||||
|
||||
log.Printf("Sending websocket close message")
|
||||
err = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
if err != nil {
|
||||
log.Printf("Problem closing websocket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *BoardServer) Listen() (string, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
go func() {
|
||||
err = server.httpServer.Serve(listener)
|
||||
if err != http.ErrServerClosed {
|
||||
log.Printf("Error in board HTTP server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
url := "http://" + listener.Addr().String()
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (server *BoardServer) Shutdown() {
|
||||
close(server.events)
|
||||
|
||||
log.Printf("Waiting for websocket clients to finish")
|
||||
<-server.done
|
||||
log.Printf("Server is done, exiting")
|
||||
|
||||
err := server.httpServer.Shutdown(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("Error shutting down HTTP server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *BoardServer) SendEvent(event GameEvent) {
|
||||
server.events <- event
|
||||
}
|
||||
|
|
@ -16,22 +16,28 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/BattlesnakeOfficial/rules"
|
||||
"github.com/BattlesnakeOfficial/rules/board"
|
||||
"github.com/BattlesnakeOfficial/rules/client"
|
||||
"github.com/BattlesnakeOfficial/rules/maps"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Used to store state for each SnakeState while running a local game
|
||||
type SnakeState struct {
|
||||
URL string
|
||||
Name string
|
||||
ID string
|
||||
LastMove string
|
||||
Character rune
|
||||
Color string
|
||||
Head string
|
||||
Tail string
|
||||
URL string
|
||||
Name string
|
||||
ID string
|
||||
LastMove string
|
||||
Character rune
|
||||
Color string
|
||||
Head string
|
||||
Tail string
|
||||
Author string
|
||||
Version string
|
||||
Error error
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
|
|
@ -51,6 +57,8 @@ type GameState struct {
|
|||
TurnDelay int
|
||||
DebugRequests bool
|
||||
Output string
|
||||
ViewInBrowser bool
|
||||
BoardURL string
|
||||
FoodSpawnChance int
|
||||
MinimumFood int
|
||||
HazardDamagePerTurn int
|
||||
|
|
@ -92,6 +100,8 @@ func NewPlayCommand() *cobra.Command {
|
|||
playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
|
||||
playCmd.Flags().BoolVar(&gameState.DebugRequests, "debug-requests", false, "Log body of all requests sent")
|
||||
playCmd.Flags().StringVarP(&gameState.Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
|
||||
playCmd.Flags().BoolVar(&gameState.ViewInBrowser, "browser", false, "View the game in the browser using the Battlesnake game board")
|
||||
playCmd.Flags().StringVar(&gameState.BoardURL, "board-url", "https://board.battlesnake.com", "Base URL for the game board when using --browser")
|
||||
|
||||
playCmd.Flags().IntVar(&gameState.FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
|
||||
playCmd.Flags().IntVar(&gameState.MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
|
||||
|
|
@ -167,6 +177,40 @@ func (gameState *GameState) Run() {
|
|||
gameState.printMap(boardState)
|
||||
}
|
||||
|
||||
boardGame := board.Game{
|
||||
ID: gameState.gameID,
|
||||
Status: "running",
|
||||
Width: gameState.Width,
|
||||
Height: gameState.Height,
|
||||
Ruleset: map[string]string{
|
||||
rules.ParamGameType: gameState.GameType,
|
||||
},
|
||||
RulesetName: gameState.GameType,
|
||||
RulesStages: []string{},
|
||||
Map: gameState.MapName,
|
||||
}
|
||||
boardServer := board.NewBoardServer(boardGame)
|
||||
|
||||
if gameState.ViewInBrowser {
|
||||
serverURL, err := boardServer.Listen()
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting HTTP server: %v", err)
|
||||
}
|
||||
log.Printf("Board server listening on %s", serverURL)
|
||||
|
||||
boardURL := fmt.Sprintf(gameState.BoardURL+"?engine=%s&game=%s&autoplay=true", serverURL, gameState.gameID)
|
||||
|
||||
log.Printf("Opening board URL: %s", boardURL)
|
||||
if err := browser.OpenURL(boardURL); err != nil {
|
||||
log.Printf("Failed to open browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if gameState.ViewInBrowser {
|
||||
// send turn zero to websocket server
|
||||
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
|
||||
}
|
||||
|
||||
var endTime time.Time
|
||||
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
|
||||
if gameState.TurnDuration > 0 {
|
||||
|
|
@ -205,6 +249,9 @@ func (gameState *GameState) Run() {
|
|||
time.Sleep(time.Until(endTime))
|
||||
}
|
||||
|
||||
if gameState.ViewInBrowser {
|
||||
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
|
||||
}
|
||||
}
|
||||
|
||||
isDraw := true
|
||||
|
|
@ -239,6 +286,13 @@ func (gameState *GameState) Run() {
|
|||
}
|
||||
}
|
||||
|
||||
if gameState.ViewInBrowser {
|
||||
boardServer.SendEvent(board.GameEvent{
|
||||
EventType: board.EVENT_TYPE_GAME_END,
|
||||
Data: boardGame,
|
||||
})
|
||||
}
|
||||
|
||||
if exportGame {
|
||||
err := gameExporter.FlushToFile(gameState.Output, "JSONL")
|
||||
if err != nil {
|
||||
|
|
@ -246,6 +300,10 @@ func (gameState *GameState) Run() {
|
|||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if gameState.ViewInBrowser {
|
||||
boardServer.Shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
|
||||
|
|
@ -449,26 +507,39 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
|
|||
snakeState := SnakeState{
|
||||
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
|
||||
}
|
||||
var snakeErr error
|
||||
res, err := gameState.httpClient.Get(snakeURL)
|
||||
if err != nil {
|
||||
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
|
||||
} else if res.Body != nil {
|
||||
defer res.Body.Close()
|
||||
body, readErr := ioutil.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
log.Fatal(readErr)
|
||||
}
|
||||
snakeErr = err
|
||||
} else {
|
||||
snakeState.StatusCode = res.StatusCode
|
||||
|
||||
pingResponse := client.SnakeMetadataResponse{}
|
||||
jsonErr := json.Unmarshal(body, &pingResponse)
|
||||
if jsonErr != nil {
|
||||
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
|
||||
} else {
|
||||
snakeState.Head = pingResponse.Head
|
||||
snakeState.Tail = pingResponse.Tail
|
||||
snakeState.Color = pingResponse.Color
|
||||
if res.Body != nil {
|
||||
defer res.Body.Close()
|
||||
body, readErr := ioutil.ReadAll(res.Body)
|
||||
if readErr != nil {
|
||||
log.Fatal(readErr)
|
||||
}
|
||||
|
||||
pingResponse := client.SnakeMetadataResponse{}
|
||||
jsonErr := json.Unmarshal(body, &pingResponse)
|
||||
if jsonErr != nil {
|
||||
snakeErr = jsonErr
|
||||
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
|
||||
} else {
|
||||
snakeState.Head = pingResponse.Head
|
||||
snakeState.Tail = pingResponse.Tail
|
||||
snakeState.Color = pingResponse.Color
|
||||
snakeState.Author = pingResponse.Author
|
||||
snakeState.Version = pingResponse.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
if snakeErr != nil {
|
||||
snakeState.Error = snakeErr
|
||||
}
|
||||
|
||||
snakes[snakeState.ID] = snakeState
|
||||
}
|
||||
return snakes
|
||||
|
|
@ -546,6 +617,59 @@ func (gameState *GameState) printMap(boardState *rules.BoardState) {
|
|||
log.Print(o.String())
|
||||
}
|
||||
|
||||
func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.GameEvent {
|
||||
snakes := []board.Snake{}
|
||||
|
||||
for _, snake := range boardState.Snakes {
|
||||
snakeState := gameState.snakeStates[snake.ID]
|
||||
|
||||
convertedSnake := board.Snake{
|
||||
ID: snake.ID,
|
||||
Name: snakeState.Name,
|
||||
Body: snake.Body,
|
||||
Health: snake.Health,
|
||||
Color: snakeState.Color,
|
||||
HeadType: snakeState.Head,
|
||||
TailType: snakeState.Tail,
|
||||
Author: snakeState.Author,
|
||||
StatusCode: snakeState.StatusCode,
|
||||
IsBot: false,
|
||||
IsEnvironment: false,
|
||||
|
||||
// Not supporting local latency for now - there are better ways to test performance locally
|
||||
Latency: "1",
|
||||
}
|
||||
if snakeState.Error != nil {
|
||||
// Instead of trying to keep in sync with the production engine's
|
||||
// error detection and messages, just show a generic error and rely
|
||||
// on the CLI logs to show what really happened.
|
||||
convertedSnake.Error = "0:Error communicating with server"
|
||||
} else if snakeState.StatusCode != http.StatusOK {
|
||||
convertedSnake.Error = fmt.Sprintf("7:Bad HTTP status code %d", snakeState.StatusCode)
|
||||
}
|
||||
if snake.EliminatedCause != rules.NotEliminated {
|
||||
convertedSnake.Death = &board.Death{
|
||||
Cause: snake.EliminatedCause,
|
||||
Turn: snake.EliminatedOnTurn,
|
||||
EliminatedBy: snake.EliminatedBy,
|
||||
}
|
||||
}
|
||||
snakes = append(snakes, convertedSnake)
|
||||
}
|
||||
|
||||
gameFrame := board.GameFrame{
|
||||
Turn: boardState.Turn,
|
||||
Snakes: snakes,
|
||||
Food: boardState.Food,
|
||||
Hazards: boardState.Hazards,
|
||||
}
|
||||
|
||||
return board.GameEvent{
|
||||
EventType: board.EVENT_TYPE_FRAME,
|
||||
Data: gameFrame,
|
||||
}
|
||||
}
|
||||
|
||||
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
|
||||
requestJSON, err := json.Marshal(snakeRequest)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/BattlesnakeOfficial/rules"
|
||||
"github.com/BattlesnakeOfficial/rules/board"
|
||||
"github.com/BattlesnakeOfficial/rules/client"
|
||||
"github.com/BattlesnakeOfficial/rules/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -221,3 +222,149 @@ func TestConvertRulesSnakes(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFrameEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
boardState *rules.BoardState
|
||||
snakeStates map[string]SnakeState
|
||||
expected board.GameEvent
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
boardState: rules.NewBoardState(11, 11),
|
||||
snakeStates: map[string]SnakeState{},
|
||||
expected: board.GameEvent{
|
||||
EventType: board.EVENT_TYPE_FRAME,
|
||||
Data: board.GameFrame{
|
||||
Turn: 0,
|
||||
Snakes: []board.Snake{},
|
||||
Food: []rules.Point{},
|
||||
Hazards: []rules.Point{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "snake fields",
|
||||
boardState: &rules.BoardState{
|
||||
Turn: 99,
|
||||
Height: 19,
|
||||
Width: 25,
|
||||
Food: []rules.Point{{X: 9, Y: 4}},
|
||||
Snakes: []rules.Snake{
|
||||
{
|
||||
ID: "1",
|
||||
Body: []rules.Point{
|
||||
{X: 9, Y: 4},
|
||||
{X: 8, Y: 4},
|
||||
{X: 7, Y: 4},
|
||||
},
|
||||
Health: 97,
|
||||
EliminatedCause: rules.EliminatedBySelfCollision,
|
||||
EliminatedOnTurn: 45,
|
||||
EliminatedBy: "1",
|
||||
},
|
||||
},
|
||||
Hazards: []rules.Point{{X: 8, Y: 6}},
|
||||
},
|
||||
snakeStates: map[string]SnakeState{
|
||||
"1": {
|
||||
URL: "http://example.com",
|
||||
Name: "One",
|
||||
ID: "1",
|
||||
LastMove: "left",
|
||||
Color: "#ff00ff",
|
||||
Head: "silly",
|
||||
Tail: "default",
|
||||
Author: "AUTHOR",
|
||||
Version: "1.5",
|
||||
Error: nil,
|
||||
StatusCode: 200,
|
||||
},
|
||||
},
|
||||
expected: board.GameEvent{
|
||||
EventType: board.EVENT_TYPE_FRAME,
|
||||
|
||||
Data: board.GameFrame{
|
||||
Turn: 99,
|
||||
Snakes: []board.Snake{
|
||||
{
|
||||
ID: "1",
|
||||
Name: "One",
|
||||
Body: []rules.Point{{X: 9, Y: 4}, {X: 8, Y: 4}, {X: 7, Y: 4}},
|
||||
Health: 97,
|
||||
Death: &board.Death{
|
||||
Cause: rules.EliminatedBySelfCollision,
|
||||
Turn: 45,
|
||||
EliminatedBy: "1",
|
||||
},
|
||||
Color: "#ff00ff",
|
||||
HeadType: "silly",
|
||||
TailType: "default",
|
||||
Latency: "1",
|
||||
Author: "AUTHOR",
|
||||
StatusCode: 200,
|
||||
Error: "",
|
||||
IsBot: false,
|
||||
IsEnvironment: false,
|
||||
},
|
||||
},
|
||||
Food: []rules.Point{{X: 9, Y: 4}},
|
||||
Hazards: []rules.Point{{X: 8, Y: 6}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "snake errors",
|
||||
boardState: &rules.BoardState{
|
||||
Height: 19,
|
||||
Width: 25,
|
||||
Snakes: []rules.Snake{
|
||||
{
|
||||
ID: "bad_status",
|
||||
},
|
||||
{
|
||||
ID: "connection_error",
|
||||
},
|
||||
},
|
||||
},
|
||||
snakeStates: map[string]SnakeState{
|
||||
"bad_status": {
|
||||
StatusCode: 504,
|
||||
},
|
||||
"connection_error": {
|
||||
Error: fmt.Errorf("error connecting to host"),
|
||||
},
|
||||
},
|
||||
expected: board.GameEvent{
|
||||
EventType: board.EVENT_TYPE_FRAME,
|
||||
|
||||
Data: board.GameFrame{
|
||||
Snakes: []board.Snake{
|
||||
{
|
||||
ID: "bad_status",
|
||||
Latency: "1",
|
||||
StatusCode: 504,
|
||||
Error: "7:Bad HTTP status code 504",
|
||||
},
|
||||
{
|
||||
ID: "connection_error",
|
||||
Latency: "1",
|
||||
StatusCode: 0,
|
||||
Error: "0:Error communicating with server",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gameState := GameState{
|
||||
snakeStates: test.snakeStates,
|
||||
}
|
||||
actual := gameState.buildFrameEvent(test.boardState)
|
||||
require.Equalf(t, test.expected, actual, "%#v", actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -4,7 +4,10 @@ go 1.18
|
|||
|
||||
require (
|
||||
github.com/google/uuid v1.1.2
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
|
||||
github.com/rs/cors v1.8.2
|
||||
github.com/spf13/cobra v1.1.1
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/stretchr/testify v1.4.0
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -70,6 +70,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
|||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
|
|
@ -135,6 +136,8 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn
|
|||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
|
@ -151,6 +154,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
|
|||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U=
|
||||
github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
|
|
@ -250,6 +255,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue