diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b2408ff
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/buildtools
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..73f69e0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/hexmap.iml b/.idea/hexmap.iml
new file mode 100644
index 0000000..7ccc5ba
--- /dev/null
+++ b/.idea/hexmap.iml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..4f9746b
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/protoeditor.xml b/.idea/protoeditor.xml
new file mode 100644
index 0000000..8af70d2
--- /dev/null
+++ b/.idea/protoeditor.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build/build.go b/build/build.go
new file mode 100644
index 0000000..34d7231
--- /dev/null
+++ b/build/build.go
@@ -0,0 +1,51 @@
+package build
+
+import (
+ "context"
+ "github.com/magefile/mage/mg"
+ "os"
+ "os/exec"
+ "path/filepath"
+)
+
+func GetBuildToolsDir() (string, error) {
+ basedir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(basedir, "buildtools"), nil
+}
+
+func HasExecutableInBuildtools(name string) (bool, error) {
+ tooldir, err := GetBuildToolsDir()
+ if err != nil {
+ return false, err
+ }
+ info, err := os.Stat(filepath.Join(tooldir, name))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ } else {
+ return false, err
+ }
+ }
+ if info.Mode() & 0100 != 0 {
+ return true, nil
+ } else {
+ return false, nil
+ }
+}
+
+func InstallGoExecutable(ctx context.Context, packageNameWithVersion string) error {
+ tooldir, err := GetBuildToolsDir()
+ if err != nil {
+ return err
+ }
+ cmd := exec.CommandContext(ctx, "go", "install", packageNameWithVersion)
+ cmd.Env = append(os.Environ(), "GOBIN=" + tooldir)
+ cmd.Stderr = os.Stderr
+ if mg.Verbose() {
+ cmd.Stdout = os.Stdout
+ }
+ return cmd.Run()
+}
\ No newline at end of file
diff --git a/client/magefile.go b/client/magefile.go
new file mode 100644
index 0000000..c4e7b4c
--- /dev/null
+++ b/client/magefile.go
@@ -0,0 +1,5 @@
+// +build mage
+
+package client
+
+const ProtocPluginPath = "client/node_modules/.bin/protoc-gen-ts_proto"
\ No newline at end of file
diff --git a/client/package-lock.json b/client/package-lock.json
index e48b31e..92ea28e 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -20,6 +20,7 @@
"react-color": "^2.19.3",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
+ "ts-proto": "^1.82.2",
"typescript": "^4.3.5",
"web-vitals": "^1.1.2"
}
@@ -2735,6 +2736,60 @@
"node": ">= 8"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78="
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A="
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E="
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik="
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0="
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q="
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
+ },
"node_modules/@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
@@ -3536,6 +3591,11 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
},
+ "node_modules/@types/long": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
+ "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
+ },
"node_modules/@types/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz",
@@ -3551,6 +3611,11 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
"integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA=="
},
+ "node_modules/@types/object-hash": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-1.3.4.tgz",
+ "integrity": "sha512-xFdpkAkikBgqBdG9vIlsqffDV8GpvnPEzs0IUtr1v3BEB97ijsFQ4RXVbUZwjFThhB4MDSTUfvmxUD5PGx0wXA=="
+ },
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -6713,6 +6778,11 @@
"node": ">=10"
}
},
+ "node_modules/dataloader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz",
+ "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="
+ },
"node_modules/debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
@@ -13132,6 +13202,11 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
+ "node_modules/long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -13922,6 +13997,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz",
+ "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==",
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
@@ -15873,6 +15956,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/prettier": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
+ "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==",
+ "bin": {
+ "prettier": "bin-prettier.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -15998,6 +16092,36 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/protobufjs": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz",
+ "integrity": "sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.1",
+ "@types/node": ">=13.7.0",
+ "long": "^4.0.0"
+ },
+ "bin": {
+ "pbjs": "bin/pbjs",
+ "pbts": "bin/pbts"
+ }
+ },
+ "node_modules/protobufjs/node_modules/@types/node": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.3.2.tgz",
+ "integrity": "sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw=="
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -19320,6 +19444,46 @@
}
}
},
+ "node_modules/ts-poet": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-4.5.0.tgz",
+ "integrity": "sha512-Vs2Zsiz3zf5qdFulFTIEpaLdgWeHXKh+4pv+ycVqEh+ZuUOVGrN0i9lbxVx7DB1FBogExytz3OuaBMJfWffpSQ==",
+ "dependencies": {
+ "@types/prettier": "^1.19.0",
+ "lodash": "^4.17.15",
+ "prettier": "^2.0.2"
+ }
+ },
+ "node_modules/ts-poet/node_modules/@types/prettier": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.19.1.tgz",
+ "integrity": "sha512-5qOlnZscTn4xxM5MeGXAMOsIOIKIbh9e85zJWfBRVPlRMEVawzoPhINYbRGkBZCI8LxvBe7tJCdWiarA99OZfQ=="
+ },
+ "node_modules/ts-proto": {
+ "version": "1.82.2",
+ "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-1.82.2.tgz",
+ "integrity": "sha512-Hceptz4oCnUTeGYbGepNNDg7drYOdv0M+m3LSv8V5hehsilsx5Ca8bhUAYGcQad+CDvbeUDM6PrzW4GGfJXi9A==",
+ "dependencies": {
+ "@types/object-hash": "^1.3.0",
+ "dataloader": "^1.4.0",
+ "object-hash": "^1.3.1",
+ "protobufjs": "^6.8.8",
+ "ts-poet": "^4.5.0",
+ "ts-proto-descriptors": "^1.2.1"
+ },
+ "bin": {
+ "protoc-gen-ts_proto": "protoc-gen-ts_proto"
+ }
+ },
+ "node_modules/ts-proto-descriptors": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-1.3.1.tgz",
+ "integrity": "sha512-Cybb3fqceMwA6JzHdC32dIo8eVGVmXrM6TWhdk1XQVVHT/6OQqk0ioyX1dIdu3rCIBhRmWUhUE4HsyK+olmgMw==",
+ "dependencies": {
+ "long": "^4.0.0",
+ "protobufjs": "^6.8.8"
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.10.1.tgz",
@@ -23559,6 +23723,60 @@
}
}
},
+ "@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78="
+ },
+ "@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
+ },
+ "@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
+ },
+ "@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A="
+ },
+ "@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=",
+ "requires": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E="
+ },
+ "@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik="
+ },
+ "@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0="
+ },
+ "@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q="
+ },
+ "@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA="
+ },
"@rollup/plugin-node-resolve": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz",
@@ -24147,6 +24365,11 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
},
+ "@types/long": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
+ "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
+ },
"@types/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz",
@@ -24162,6 +24385,11 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
"integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA=="
},
+ "@types/object-hash": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-1.3.4.tgz",
+ "integrity": "sha512-xFdpkAkikBgqBdG9vIlsqffDV8GpvnPEzs0IUtr1v3BEB97ijsFQ4RXVbUZwjFThhB4MDSTUfvmxUD5PGx0wXA=="
+ },
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -26695,6 +26923,11 @@
"whatwg-url": "^8.0.0"
}
},
+ "dataloader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz",
+ "integrity": "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="
+ },
"debug": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
@@ -31501,6 +31734,11 @@
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz",
"integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw=="
},
+ "long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
+ },
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -32141,6 +32379,11 @@
}
}
},
+ "object-hash": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz",
+ "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA=="
+ },
"object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
@@ -33677,6 +33920,11 @@
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
},
+ "prettier": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
+ "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ=="
+ },
"pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -33779,6 +34027,33 @@
}
}
},
+ "protobufjs": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz",
+ "integrity": "sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==",
+ "requires": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.1",
+ "@types/node": ">=13.7.0",
+ "long": "^4.0.0"
+ },
+ "dependencies": {
+ "@types/node": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.3.2.tgz",
+ "integrity": "sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw=="
+ }
+ }
+ },
"proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -36409,6 +36684,45 @@
"resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz",
"integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw=="
},
+ "ts-poet": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-4.5.0.tgz",
+ "integrity": "sha512-Vs2Zsiz3zf5qdFulFTIEpaLdgWeHXKh+4pv+ycVqEh+ZuUOVGrN0i9lbxVx7DB1FBogExytz3OuaBMJfWffpSQ==",
+ "requires": {
+ "@types/prettier": "^1.19.0",
+ "lodash": "^4.17.15",
+ "prettier": "^2.0.2"
+ },
+ "dependencies": {
+ "@types/prettier": {
+ "version": "1.19.1",
+ "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-1.19.1.tgz",
+ "integrity": "sha512-5qOlnZscTn4xxM5MeGXAMOsIOIKIbh9e85zJWfBRVPlRMEVawzoPhINYbRGkBZCI8LxvBe7tJCdWiarA99OZfQ=="
+ }
+ }
+ },
+ "ts-proto": {
+ "version": "1.82.2",
+ "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-1.82.2.tgz",
+ "integrity": "sha512-Hceptz4oCnUTeGYbGepNNDg7drYOdv0M+m3LSv8V5hehsilsx5Ca8bhUAYGcQad+CDvbeUDM6PrzW4GGfJXi9A==",
+ "requires": {
+ "@types/object-hash": "^1.3.0",
+ "dataloader": "^1.4.0",
+ "object-hash": "^1.3.1",
+ "protobufjs": "^6.8.8",
+ "ts-poet": "^4.5.0",
+ "ts-proto-descriptors": "^1.2.1"
+ }
+ },
+ "ts-proto-descriptors": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-1.3.1.tgz",
+ "integrity": "sha512-Cybb3fqceMwA6JzHdC32dIo8eVGVmXrM6TWhdk1XQVVHT/6OQqk0ioyX1dIdu3rCIBhRmWUhUE4HsyK+olmgMw==",
+ "requires": {
+ "long": "^4.0.0",
+ "protobufjs": "^6.8.8"
+ }
+ },
"tsconfig-paths": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.10.1.tgz",
diff --git a/client/package.json b/client/package.json
index 45838e8..9a45788 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,6 +15,7 @@
"react-color": "^2.19.3",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
+ "ts-proto": "^1.82.2",
"typescript": "^4.3.5",
"web-vitals": "^1.1.2"
},
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..6d20048
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,12 @@
+module git.reya.zone/reya/hexmap
+
+go 1.16
+
+require (
+ github.com/gorilla/websocket v1.4.2
+ github.com/magefile/mage v1.11.0
+ github.com/rs/xid v1.3.0
+ go.uber.org/zap v1.18.1
+ golang.org/x/tools v0.0.0-20210106214847-113979e3529a // indirect
+ google.golang.org/protobuf v1.27.1
+)
diff --git a/server/go.sum b/go.sum
similarity index 62%
rename from server/go.sum
rename to go.sum
index bb39d18..72e0f0b 100644
--- a/server/go.sum
+++ b/go.sum
@@ -3,11 +3,16 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
+github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -19,6 +24,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
@@ -28,17 +34,36 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i
go.uber.org/zap v1.18.1 h1:CSUJ2mjFszzEWt4CdKISEuChVIXGBn3lAPwkRGyVrc4=
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20191108193012-7d206e10da11 h1:Yq9t9jnGoR+dBuitxdo9l6Q7xh/zOyNnYUtDKaQ3x0E=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a h1:CB3a9Nez8M13wwlr/E2YtwoU+qYHKfC+JrDa45RXXoQ=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/mage.sh b/mage.sh
new file mode 100755
index 0000000..0895ffc
--- /dev/null
+++ b/mage.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+MAINPATH=$(readlink -e ${BASH_SOURCE%/*})
+BUILDTOOLSPATH=${MAINPATH}/buildtools
+MAGEPATH=${BUILDTOOLSPATH}/mage
+
+if [[ ! -x "$MAGEPATH" ]]; then
+ echo "go install-ing mage..."
+ GOBIN="$BUILDTOOLSPATH" go install github.com/magefile/mage@latest
+fi
+
+exec "$MAGEPATH" -d "$MAINPATH" -w "$MAINPATH" "$@"
\ No newline at end of file
diff --git a/magefile.go b/magefile.go
new file mode 100644
index 0000000..a2148eb
--- /dev/null
+++ b/magefile.go
@@ -0,0 +1,8 @@
+// +build mage
+
+package main
+
+import (
+ // mage:import
+ _ "git.reya.zone/reya/hexmap/server"
+)
diff --git a/proto/action.proto b/proto/action.proto
new file mode 100644
index 0000000..73b20a5
--- /dev/null
+++ b/proto/action.proto
@@ -0,0 +1,14 @@
+syntax = "proto3";
+
+import "coords.proto";
+
+option go_package = "git.reya.zone/reya/hexmap/server/action";
+
+message CellSetColor {
+ fixed32 color = 1;
+ StorageCoordinates at = 2;
+}
+
+message UserSetActiveColor {
+ fixed32 color = 1;
+}
\ No newline at end of file
diff --git a/proto/client.proto b/proto/client.proto
new file mode 100644
index 0000000..f51fcbe
--- /dev/null
+++ b/proto/client.proto
@@ -0,0 +1,31 @@
+syntax = "proto3";
+
+import "action.proto";
+
+option go_package = "git.reya.zone/reya/hexmap/server/websocket";
+
+message ClientHello {
+ uint32 version = 1;
+}
+
+message ClientRefresh {
+}
+
+message ClientAct {
+ message ClientAction {
+ uint32 id = 1;
+ oneof action {
+ CellSetColor cell_set_color = 2;
+ UserSetActiveColor user_set_active_color = 3;
+ }
+ }
+ repeated ClientAction actions = 1;
+}
+
+message ClientCommand {
+ oneof command {
+ ClientHello hello = 1;
+ ClientRefresh refresh = 2;
+ ClientAct act = 3;
+ }
+}
\ No newline at end of file
diff --git a/proto/coords.proto b/proto/coords.proto
new file mode 100644
index 0000000..8634b29
--- /dev/null
+++ b/proto/coords.proto
@@ -0,0 +1,8 @@
+syntax = "proto3";
+
+option go_package = "git.reya.zone/reya/hexmap/server/state";
+
+message StorageCoordinates {
+ uint32 line = 1;
+ uint32 cell = 2;
+}
\ No newline at end of file
diff --git a/proto/map.proto b/proto/map.proto
new file mode 100644
index 0000000..98ac842
--- /dev/null
+++ b/proto/map.proto
@@ -0,0 +1,33 @@
+syntax = "proto3";
+
+option go_package = "git.reya.zone/reya/hexmap/server/state";
+
+message HexCell {
+ fixed32 color = 1;
+}
+
+message HexLine {
+ repeated HexCell cells = 1;
+}
+
+message HexMap {
+ message Layout {
+ enum Orientation {
+ UNKNOWN_ORIENTATION = 0;
+ FLAT_TOP_ORIENTATION = 1;
+ POINTY_TOP_ORIENTATION = 2;
+ }
+ enum LineParity {
+ UNKNOWN_LINE_PARITY = 0;
+ EVEN_LINE_PARITY = 1;
+ ODD_LINE_PARITY = 2;
+ }
+ Orientation orientation = 1;
+ LineParity line_parity = 2;
+ }
+ string xid = 1;
+ uint32 lines = 2;
+ uint32 cells_per_line = 3;
+ Layout layout = 4;
+ repeated HexLine line_cells = 5;
+}
diff --git a/proto/server.proto b/proto/server.proto
new file mode 100644
index 0000000..1a2f6f5
--- /dev/null
+++ b/proto/server.proto
@@ -0,0 +1,41 @@
+syntax = "proto3";
+
+import "action.proto";
+import "map.proto";
+import "user.proto";
+
+option go_package = "git.reya.zone/reya/hexmap/server/websocket";
+
+message ServerState {
+ HexMap map = 1;
+ UserState user = 2;
+}
+
+message ServerHello {
+ uint32 version = 1;
+ ServerState state = 2;
+}
+
+message ServerRefresh {
+ ServerState state = 1;
+}
+
+message ServerOK {
+ repeated uint32 ids = 1;
+}
+
+message ServerFailed {
+ repeated uint32 ids = 1;
+ string error = 2;
+}
+
+message ServerAction {
+ oneof action {
+ CellSetColor cell_set_color = 1;
+ UserSetActiveColor user_set_active_color = 2;
+ }
+}
+
+message ServerAct {
+ repeated ServerAction actions = 1;
+}
\ No newline at end of file
diff --git a/proto/user.proto b/proto/user.proto
new file mode 100644
index 0000000..cc7e879
--- /dev/null
+++ b/proto/user.proto
@@ -0,0 +1,7 @@
+syntax = "proto3";
+
+option go_package = "git.reya.zone/reya/hexmap/server/state";
+
+message UserState {
+ fixed32 color = 1;
+}
\ No newline at end of file
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..9695e6e
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,2 @@
+*.pb.go
+*.zap.go
\ No newline at end of file
diff --git a/server/action/syncable.go b/server/action/action.go
similarity index 67%
rename from server/action/syncable.go
rename to server/action/action.go
index d80121a..e16a1ae 100644
--- a/server/action/syncable.go
+++ b/server/action/action.go
@@ -14,24 +14,30 @@ var (
ErrNoTransparentColors = errors.New("transparent colors not allowed")
)
-type SyncableType string
+type Type string
-// Syncable is the interface for actions that can be shared between clients.
-type Syncable interface {
+// Action is the interface for actions that can be shared between clients, or between the server and a client.
+type Action interface {
zapcore.ObjectMarshaler
// Type gives the Javascript type that is sent over the wire.
- Type() SyncableType
+ Type() Type
// Apply causes the action's effects to be applied to s, mutating it in place.
- // All syncable.Actions must conform to the standard that if an action can't be correctly applied, or if it would
+ // All Actions must conform to the standard that if an action can't be correctly applied, or if it would
// have no effect, it returns an error without changing s.
// If an action can be correctly applied but would have no effect, it should return ErrNoOp.
// If an action is correctly applied and has an effect, it should return nil.
Apply(s *state.Synced) error
+ // fromJSONMap causes the action's state to be overwritten by data from the given map.
+ fromJSONMap(data map[string] interface{}) error
}
-type SyncableSlice []Syncable
+type parseAction struct {
+ Type string `json:"type"`
+}
+
+type Slice []Action
-func (s SyncableSlice) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
+func (s Slice) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
var finalErr error = nil
for _, a := range s {
err := encoder.AppendObject(a)
diff --git a/server/action/map.go b/server/action/map.go
index 3e294cb..76fed37 100644
--- a/server/action/map.go
+++ b/server/action/map.go
@@ -6,7 +6,7 @@ import (
)
const (
- CellColorType SyncableType = "CELL_COLOR"
+ CellColorType Type = "CELL_COLOR"
)
// CellColor is the action sent when a cell of the map has been colored a different color.
@@ -24,7 +24,7 @@ func (c CellColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return err
}
-func (c CellColor) Type() SyncableType {
+func (c CellColor) Type() Type {
return CellColorType
}
diff --git a/server/action/user.go b/server/action/user.go
index 0ed22a6..2058b6f 100644
--- a/server/action/user.go
+++ b/server/action/user.go
@@ -15,10 +15,6 @@ type UserActiveColor struct {
Color state.HexColor `json:"color"`
}
-func (c UserActiveColor) Type() SyncableType {
- return UserActiveColorType
-}
-
func (c UserActiveColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("color", c.Color.String())
return nil
@@ -34,4 +30,4 @@ func (c UserActiveColor) Apply(s *state.Synced) error {
}
s.User.ActiveColor = c.Color
return nil
-}
+}
\ No newline at end of file
diff --git a/server/go.mod b/server/go.mod
deleted file mode 100644
index 44d9a86..0000000
--- a/server/go.mod
+++ /dev/null
@@ -1,9 +0,0 @@
-module git.reya.zone/reya/hexmap/server
-
-go 1.16
-
-require (
- github.com/gorilla/websocket v1.4.2
- github.com/rs/xid v1.3.0
- go.uber.org/zap v1.18.1
-)
diff --git a/server/magefile.go b/server/magefile.go
new file mode 100644
index 0000000..f64e3a8
--- /dev/null
+++ b/server/magefile.go
@@ -0,0 +1,76 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "git.reya.zone/reya/hexmap/build"
+ "github.com/magefile/mage/mg"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+
+func InstallZapMarshaler(ctx context.Context) error {
+ alreadyDone, err := build.HasExecutableInBuildtools("protoc-gen-zap-marshaler")
+ if err != nil {
+ return err
+ }
+ if alreadyDone {
+ return nil
+ }
+ return build.InstallGoExecutable(ctx, "github.com/kazegusuri/go-proto-zap-marshaler/protoc-gen-zap-marshaler@latest")
+}
+
+func InstallGoProtoc(ctx context.Context) error {
+ alreadyDone, err := build.HasExecutableInBuildtools("protoc-gen-go")
+ if err != nil {
+ return err
+ }
+ if alreadyDone {
+ return nil
+ }
+ return build.InstallGoExecutable(ctx, "google.golang.org/protobuf/cmd/protoc-gen-go@v1.27.1")
+}
+
+func InstallProtoDeps(ctx context.Context) {
+ mg.CtxDeps(ctx, InstallZapMarshaler, InstallGoProtoc)
+}
+
+type Protobuf mg.Namespace
+
+func (Protobuf) BuildProtocolBuffers(ctx context.Context) error {
+ mg.CtxDeps(ctx, InstallProtoDeps)
+ tooldir, err := build.GetBuildToolsDir()
+ if err != nil {
+ return err
+ }
+ pluginPathFlag := fmt.Sprintf("--plugin=%s", path.Join(tooldir, "protoc-gen-go"))
+ cmd := exec.CommandContext(ctx, "protoc", pluginPathFlag, "-I=proto", "--go_out=.", "--go_opt=module=git.reya.zone/reya/hexmap", "proto/action.proto", "proto/client.proto", "proto/coords.proto", "proto/map.proto", "proto/server.proto", "proto/user.proto")
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+func (Protobuf) Clean(ctx context.Context) error {
+ return filepath.WalkDir("server", func(path string, d fs.DirEntry, dirErr error) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if dirErr != nil {
+ return dirErr
+ }
+ if strings.HasSuffix(path, ".pb.go") && !d.IsDir() {
+ if mg.Verbose() {
+ fmt.Printf("Removing generated protobuf code at %s\n", path)
+ }
+ err := os.Remove(path)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
\ No newline at end of file
diff --git a/server/room/actor.go b/server/room/actor.go
index eddf0dd..94d3057 100644
--- a/server/room/actor.go
+++ b/server/room/actor.go
@@ -120,13 +120,13 @@ func (r *room) sendRefresh(id xid.ID) {
}
// applyAction applies an action to the state and returns the result of it.
-func (r *room) applyAction(action action.Syncable) error {
+func (r *room) applyAction(action action.Action) error {
r.logger.Debug("Applying action", zap.Object("action", action))
return action.Apply(&r.currentState)
}
// broadcastAction sends an action to everyone other than the original client which requested it.
-func (r *room) broadcastAction(originalClientID xid.ID, originalActionID int, action action.Syncable) {
+func (r *room) broadcastAction(originalClientID xid.ID, originalActionID int, action action.Action) {
logger := r.logger.With(zap.Stringer("originalClient", originalClientID), zap.Int("actionID", originalActionID), zap.Object("action", action))
broadcast := ActionBroadcast{
id: r.id,
diff --git a/server/room/message.go b/server/room/message.go
index ff1a0fe..35e575b 100644
--- a/server/room/message.go
+++ b/server/room/message.go
@@ -101,7 +101,7 @@ type ActionBroadcast struct {
// originalActionID is the ID that the client that sent the action sent.
originalActionID int
// action is the action that succeeded.
- action action.Syncable
+ action action.Action
}
func (a ActionBroadcast) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
diff --git a/server/websocket/client.go b/server/websocket/client.go
index 73f929b..c66a6eb 100644
--- a/server/websocket/client.go
+++ b/server/websocket/client.go
@@ -5,30 +5,31 @@ import (
"go.uber.org/zap/zapcore"
)
-// ClientMessageType is an enum type for the client's protocol messages.
-type ClientMessageType string
+// ClientCommandType is an enum type for the client's protocol messages.
+type ClientCommandType string
const (
- ClientHelloType ClientMessageType = "HELLO"
- ClientRefreshType ClientMessageType = "REFRESH"
- ClientActType ClientMessageType = "ACT"
- ClientGoodbyeType ClientMessageType = GoodbyeType
+ ClientHelloType ClientCommandType = "HELLO"
+ ClientRefreshType ClientCommandType = "REFRESH"
+ ClientActType ClientCommandType = "ACT"
+ ClientGoodbyeType ClientCommandType = GoodbyeType
+ ClientMalformedCommandType ClientCommandType = "(malformed command)"
)
-// ClientMessage s are those sent by the client.
-type ClientMessage interface {
+// ClientCommand s are those sent by the client.
+type ClientCommand interface {
zapcore.ObjectMarshaler
// ClientType gives the type constant that will be sent on or read from the wire.
- ClientType() ClientMessageType
+ ClientType() ClientCommandType
}
-// ClientHello is the action sent by the client when it first establishes the connection.
+// ClientHello is the command sent by the client when it first establishes the connection.
type ClientHello struct {
// Version is the protocol version the client is running.
Version int `json:"version"`
}
-func (c ClientHello) ClientType() ClientMessageType {
+func (c ClientHello) ClientType() ClientCommandType {
return ClientHelloType
}
@@ -38,11 +39,11 @@ func (c ClientHello) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil
}
-// ClientRefresh is the action sent by the client when it needs the full state re-sent.
+// ClientRefresh is the command sent by the client when it needs the full state re-sent.
type ClientRefresh struct {
}
-func (c ClientRefresh) ClientType() ClientMessageType {
+func (c ClientRefresh) ClientType() ClientCommandType {
return ClientRefreshType
}
@@ -56,7 +57,7 @@ type IDed struct {
// ID contains the arbitrary ID that was sent by the client, for identifying the action in future messages.
ID int `json:"id"`
// Action contains the action that was actually being sent.
- Action action.Syncable `json:"action"`
+ Action action.Action `json:"action"`
}
func (i IDed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
@@ -77,13 +78,13 @@ func (a IDPairs) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
return finalErr
}
-// ClientAct is an action sent in order to deliver one or more Syncable actions to the server.
+// ClientAct is a command sent in order to deliver one or more Action actions to the server.
type ClientAct struct {
// Actions contains the actions the client wants to apply, in the order they should be applied.
Actions IDPairs `json:"actions"`
}
-func (c ClientAct) ClientType() ClientMessageType {
+func (c ClientAct) ClientType() ClientCommandType {
return ClientActType
}
@@ -91,3 +92,20 @@ func (c ClientAct) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", string(ClientActType))
return encoder.AddArray("actions", c.Actions)
}
+
+// ClientMalformed is synthesized by the reader when it has read a command that does not appear to match the
+// protocol.
+type ClientMalformed struct {
+ // Error is the error in parse that caused the reader to be unable to read the message.
+ Error error
+}
+
+func (c ClientMalformed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
+ encoder.AddString("type", string(ClientMalformedCommandType))
+ encoder.AddString("error", c.Error.Error())
+ return nil
+}
+
+func (c ClientMalformed) ClientType() ClientCommandType {
+ return ClientMalformedCommandType
+}
diff --git a/server/websocket/connection.go b/server/websocket/connection.go
index de8165e..3bdc46f 100644
--- a/server/websocket/connection.go
+++ b/server/websocket/connection.go
@@ -26,12 +26,12 @@ type Connection struct {
// 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 ClientMessage {
+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<- ServerMessage {
+func (c *Connection) WriteChannel() chan<- ServerCommand {
return c.w.channel
}
diff --git a/server/websocket/reader.go b/server/websocket/reader.go
index 066ec73..a6d9a99 100644
--- a/server/websocket/reader.go
+++ b/server/websocket/reader.go
@@ -1,14 +1,181 @@
package websocket
import (
+ "bytes"
+ "encoding/json"
+ "fmt"
"github.com/gorilla/websocket"
+ "go.uber.org/zap"
"time"
)
-// Todo: Listen for pongs and extend the read deadline every time you get one
-
type reader struct {
+ // conn is the connection to the client read from by the reader.
conn *websocket.Conn
- channel chan ClientMessage
- readNotifications chan<- time.Duration
+ // channel is the channel that the reader sends messages it has received on.
+ channel chan ClientCommand
+ // readNotifications is the channel that alerts are sent to the writer on, to let it know to
+ readNotifications chan<- time.Time
+ // logger is the logger used to record the state of the reader, primarily in Debug level.
+ logger *zap.Logger
+}
+
+// InvalidMessageType is placed in ClientMalformed when the type of the WebSocket message was incorrect.
+type InvalidMessageType struct {
+ MessageType int
+}
+
+func (i InvalidMessageType) Error() string {
+ return fmt.Sprintf("invalid message type %d", i.MessageType)
+}
+
+// InvalidCommandType is placed in ClientMalformed when the type of the command was unknown.
+type InvalidCommandType struct {
+ CommandType ClientCommandType
+}
+
+func (i InvalidCommandType) Error() string {
+ return fmt.Sprintf("invalid command type %s", i.CommandType)
+}
+
+// InvalidPayload is placed in ClientMalformed when the payload could not be parsed.
+type InvalidPayload struct {
+ CommandType ClientCommandType
+ Cause error
}
+
+func (i InvalidPayload) Error() string {
+ return fmt.Sprintf("command type %s had invalid payload: %s", i.CommandType, i.Cause)
+}
+
+func (i InvalidPayload) Unwrap() error {
+ return i.Cause
+}
+
+// act contains the main read loop of the reader.
+func (r *reader) act() {
+ defer r.shutdown()
+ // Our first deadline starts immediately, so let the writer know.
+ r.updateDeadlines()
+ r.conn.SetPongHandler(func(appData string) error {
+ r.logger.Debug("Received pong, extending read deadline")
+ r.updateDeadlines()
+ if appData != PingData {
+ r.logger.Warn("Got unexpected data in the pong", zap.String("appData", appData))
+ }
+ return nil
+ })
+ for {
+ messageType, messageData, err := r.conn.ReadMessage()
+ if err != nil {
+ var closure SocketClosed
+ if websocket.IsCloseError(err, StandardClientCloseTypes...) {
+ typedErr := err.(*websocket.CloseError)
+ r.logger.Debug("Received normal close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text))
+ closure = SocketClosed { Code: typedErr.Code, Text: typedErr.Text }
+ } else if websocket.IsUnexpectedCloseError(err, StandardClientCloseTypes...) {
+ typedErr := err.(*websocket.CloseError)
+ r.logger.Warn("Received unexpected close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text))
+ closure = SocketClosed { Code: typedErr.Code, Text: typedErr.Text }
+ } else {
+ r.logger.Error("Error while reading message, shutting down", zap.Error(err))
+ closure = SocketClosed { Error: err }
+ }
+ r.logger.Debug("Sending close message to reader", zap.Object("closeMessage", closure))
+ r.channel <- closure
+ // We must exit now - errors from this method are permanent, after all.
+ // We'll do the shutdown we deferred.
+ return
+ }
+ r.updateDeadlines()
+ r.channel <- r.parseCommand(messageType, messageData)
+ }
+}
+
+// parseCommand attempts to parse the incoming message
+func (r *reader) parseCommand(socketType int, data []byte) ClientCommand {
+ if socketType != websocket.TextMessage {
+ err := &InvalidMessageType{
+ MessageType: socketType,
+ }
+ r.logger.Error("Received command with unknown WebSocket message type", zap.Error(err))
+ return ClientMalformed{
+ Error: err,
+ }
+ }
+ r.logger.Debug("Received command, parse")
+ parts := bytes.SplitN(data, []byte(" "), 2)
+ commandBytes := parts[0]
+ payloadJson := parts[1]
+ var command ClientCommandType
+ if len(payloadJson) == 0 {
+ // Since there's no payload, we expect the command to end with an exclamation point.
+ if bytes.HasSuffix(commandBytes, []byte("!")) {
+ command = ClientCommandType(bytes.TrimSuffix(commandBytes, []byte("!")))
+ } else {
+ r.logger.Warn("Received command not fitting the protocol: has no payload but no ! after command type")
+ command = ClientCommandType(commandBytes)
+ }
+ } else {
+ command = ClientCommandType(commandBytes)
+ }
+ switch command {
+ case ClientHelloType:
+ hello := &ClientHello{}
+ err := json.Unmarshal(payloadJson, hello)
+ if err != nil {
+ return ClientMalformed{
+ Error: &InvalidPayload{
+ CommandType: command,
+ Cause: err,
+ },
+ }
+ }
+ return hello
+ case ClientRefreshType:
+ if len(payloadJson) != 0 {
+ r.logger.Warn("Received command not fitting the protocol: has payload for payloadless Refresh command")
+ }
+ refresh := &ClientRefresh{}
+ return refresh
+ case ClientActType:
+ act := &ClientAct{}
+ err := json.Unmarshal(payloadJson, act)
+ if err != nil {
+ return ClientMalformed{
+ Error: &InvalidPayload{
+ CommandType: command,
+ Cause: err,
+ },
+ }
+ }
+ return act
+ default:
+ return ClientMalformed{
+ Error: InvalidCommandType{CommandType: command},
+ }
+ }
+}
+
+// updateDeadlines extends the time limit for pongs, and instructs the writer to hold off on sending a ping for the next PingDelay.
+func (r *reader) updateDeadlines() {
+ receivedAt := time.Now()
+ r.logger.Debug("Alerting writer to extend ping timer", zap.Time("receivedAt", receivedAt))
+ r.readNotifications <- receivedAt
+ newDeadline := receivedAt.Add(ReadTimeLimit)
+ r.logger.Debug("Extending read deadline", zap.Time("newDeadline", newDeadline))
+ err := r.conn.SetReadDeadline(newDeadline)
+ if err != nil {
+ r.logger.Error("Error while extending read deadline", zap.Error(err))
+ }
+ r.logger.Debug("Read deadline extended")
+}
+
+// shutdown closes all resources associated with the reader (the channel and readNotifications) but leaves its conn running.
+func (r *reader) shutdown() {
+ close(r.channel)
+ r.channel = nil
+ close(r.readNotifications)
+ r.readNotifications = nil
+ r.conn = nil
+}
\ No newline at end of file
diff --git a/server/websocket/server.go b/server/websocket/server.go
index d318672..499e2a2 100644
--- a/server/websocket/server.go
+++ b/server/websocket/server.go
@@ -18,14 +18,14 @@ const (
ServerGoodbyeType ServerMessageType = GoodbyeType
)
-// ServerMessage s are sent by the server to the client.
-type ServerMessage interface {
+// ServerCommand s are sent by the server to the client.
+type ServerCommand interface {
zapcore.ObjectMarshaler
// ServerType returns the type constant that will be sent on the wire.
ServerType() ServerMessageType
}
-// ServerHello is the action sent to establish the current state of the server when a new client connects.
+// ServerHello is the command sent to establish the current state of the server when a new client connects.
type ServerHello struct {
// Version is the protocol version the server is running.
Version int `json:"version"`
@@ -43,7 +43,7 @@ func (s ServerHello) ServerType() ServerMessageType {
return ServerHelloType
}
-// ServerRefresh is the action sent to reestablish the current state of the server in response to ClientRefresh.
+// ServerRefresh is the command sent to reestablish the current state of the server in response to ClientRefresh.
type ServerRefresh struct {
// State is the complete state of the server as of when the corresponding ClientRefresh was processed.
State *state.Synced `json:"state"`
@@ -67,7 +67,7 @@ func (i IDSlice) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
return nil
}
-// ServerOK is the action sent when one or more client actions have been accepted and applied.
+// ServerOK is the command sent when one or more client actions have been accepted and applied.
type ServerOK struct {
// IDs contains the IDs of the actions which were accepted and applied, in the order they were accepted and applied.
// This is the same as the order they were received, though other actions may have been between these that were
@@ -84,7 +84,7 @@ func (s ServerOK) ServerType() ServerMessageType {
return ServerOKType
}
-// ServerFailed is the action sent when one or more client actions have been rejected.
+// ServerFailed is the command sent when one or more client actions have been rejected.
type ServerFailed struct {
// IDs contains the IDs of the actions which were rejected, in the order they were rejected.
// This is the same as the order they were received, though other actions may have been between these that were
@@ -105,11 +105,11 @@ func (s ServerFailed) ServerType() ServerMessageType {
return ServerFailedType
}
-// ServerAct is the action sent when one or more client actions from other clients have been accepted and applied.
-// The client's own actions will never be included in this action.
+// ServerAct is the command sent when one or more client actions from other clients have been accepted and applied.
+// The client's own actions will never be included in this command.
type ServerAct struct {
// Actions contains the actions that are now being applied.
- Actions action.SyncableSlice `json:"actions"`
+ Actions action.Slice `json:"actions"`
}
func (s ServerAct) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
diff --git a/server/websocket/shared.go b/server/websocket/shared.go
index 0417a4b..6354d33 100644
--- a/server/websocket/shared.go
+++ b/server/websocket/shared.go
@@ -1,29 +1,28 @@
package websocket
-import "go.uber.org/zap/zapcore"
-
-// StatusCode is the code used by the WebSocket protocol to signal the other side on close.
-type StatusCode int16
+import (
+ "github.com/gorilla/websocket"
+ "go.uber.org/zap/zapcore"
+)
const (
- StatusNormal StatusCode = 1000
- StatusGoingAway StatusCode = 1001
- StatusProtocolError StatusCode = 1002
- StatusTooBig StatusCode = 1009
- StatusProtocolVersionOutOfDate StatusCode = 4000
+ PingData string = "are you still there?"
GoodbyeType = "GOODBYE"
)
+var StandardClientCloseTypes = []int { websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure }
+
// SocketClosed is synthesized when a client closes the WebSocket connection, or sent to the write process to write a
// WebSocket close message.
// Sending a SocketClosed on a channel causes that channel to be closed right after.
type SocketClosed struct {
- // Code is the StatusCode given (or which should be given) in the close message.
- Code StatusCode `json:"code"`
+ // Code is the status code given (or which should be given) in the close message.
+ Code int `json:"code"`
// Text is the reason text given (or which should be given) in the close message. Max 123 characters.
Text string `json:"text"`
- // Error may be an error that resulted in the closure of the socket.
+ // Error may be an error that resulted in the closure of the socket. It will be set only if the connection was cut
+ // by a condition other than receiving a close message; if it is set, Code and Text will both be zeroed.
// Will not be written by the writer; only useful when it's returned from the reader.
Error error `json:"error"`
}
@@ -38,7 +37,7 @@ func (c SocketClosed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil
}
-func (c SocketClosed) ClientType() ClientMessageType {
+func (c SocketClosed) ClientType() ClientCommandType {
return ClientGoodbyeType
}
diff --git a/server/websocket/writer.go b/server/websocket/writer.go
index 1fb84e9..5b294ee 100644
--- a/server/websocket/writer.go
+++ b/server/websocket/writer.go
@@ -14,31 +14,51 @@ type writer struct {
conn *websocket.Conn
// channel is the channel used to receive server messages to be sent to the client.
// When it receives a SocketClosed, the process on the sending end promises not to send any further messages, as the writer will close it right after.
- channel chan ServerMessage
+ channel chan ServerCommand
// readNotifications is the channel used to receive pings when the reader receives a message, so that a ping will be sent out before the reader is ready to time out.
// When it is closed, the reader has shut down.
- readNotifications <-chan time.Duration
+ readNotifications <-chan time.Time
// timer is the timer used to send pings when the reader is close to timing out, to make sure the other end of the connection is still listening.
timer *time.Timer
+ // nextPingAt is the time after which the next ping will be sent if the timer is ticking. It will be .IsZero() if the timer is not ticking.
+ nextPingAt time.Time
// logger is the logger used to record the state of the writer, primarily in Debug level.
logger *zap.Logger
}
+// IsTimerTicking returns true if the writer's timer is running.
+func (w *writer) isTimerTicking() bool {
+ return !w.nextPingAt.IsZero()
+}
+
+// sendNextPingAt starts the timer if it's not running, and
+func (w *writer) sendNextPingAt(nextPing time.Time) {
+ if w.nextPingAt.IsZero() {
+ // Timer is not running, so set the next ping time and start it.
+ w.nextPingAt = nextPing
+ w.timer.Reset(time.Until(nextPing))
+ } else if w.nextPingAt.Before(nextPing) {
+ // Timer's already running, so leave it be, but update the next ping time.
+ w.nextPingAt = nextPing
+ } else {
+ // The timer is already set to a time after the incoming time.
+ // It's extremely unlikely for this empty branch to ever be reached.
+ }
+}
+
// act is the function responsible for actually doing the writing.
func (w *writer) act() {
defer w.gracefulShutdown()
w.logger.Debug("Starting up")
w.timer = time.NewTimer(PingDelay)
+ w.nextPingAt = time.Now().Add(PingDelay)
for {
select {
- case _, open := <-w.readNotifications:
+ case readAt, open := <-w.readNotifications:
if open {
- w.logger.Debug("Received reader read, extending ping")
- if !w.timer.Stop() {
- // The timer went off while we were doing this, so drain the channel before resetting
- <-w.timer.C
- }
- w.timer.Reset(PingDelay)
+ nextPingAt := readAt.Add(PingDelay)
+ w.logger.Debug("Received reader read, extending ping timer", zap.Time("nextPingAt", nextPingAt))
+ w.sendNextPingAt(nextPingAt)
} else {
w.logger.Debug("Received reader close, shutting down")
w.readNotifications = nil
@@ -57,47 +77,68 @@ func (w *writer) act() {
w.send(msg)
}
case <-w.timer.C:
- w.sendPing()
- w.timer.Reset(PingDelay)
+ now := time.Now()
+ if now.After(w.nextPingAt) {
+ // We successfully passed the time when a ping should be sent! Let's send it!
+ w.sendPing()
+ // The timer doesn't need to be reactivated right now, so just zero out the next ping time.
+ w.nextPingAt = time.Time{}
+ } else {
+ // It's not time to send the ping yet - we got more reads in the meantime. Restart the timer with the new time-until-next-ping.
+ w.timer.Reset(w.nextPingAt.Sub(now))
+ }
}
w.logger.Debug("Awakening handled, resuming listening")
}
}
-func (w *writer) send(msg ServerMessage) {
+// send actually transmits a ServerCommand to the client according to the protocol.
+func (w *writer) send(msg ServerCommand) {
+ writeDeadline := time.Now().Add(WriteTimeLimit)
+ w.logger.Debug("Setting deadline to write command", zap.Time("writeDeadline", writeDeadline))
+ err := w.conn.SetWriteDeadline(writeDeadline)
+ if err != nil {
+ w.logger.Error("Error while setting write deadline", zap.Time("writeDeadline", writeDeadline), zap.Object("msg", msg), zap.Error(err))
+ }
+ w.logger.Debug("Opening message writer to send command", zap.Object("msg", msg))
writer, err := w.conn.NextWriter(websocket.TextMessage)
if err != nil {
- w.logger.Error("error while getting writer from connection", zap.Error(err))
+ w.logger.Error("Error while getting writer from connection", zap.Error(err))
return
}
defer func(writer io.WriteCloser) {
+ w.logger.Debug("Closing message writer to send command")
err := writer.Close()
if err != nil {
- w.logger.Error("error while closing writer to send message", zap.Error(err))
+ w.logger.Error("Error while closing writer to send command", zap.Error(err))
}
+ w.logger.Debug("Command sent")
}(writer)
+ w.logger.Debug("Marshaling command to JSON", zap.Object("msg", msg))
payload, err := json.Marshal(msg)
if err != nil {
- w.logger.Error("error while rendering message payload to JSON", zap.Error(err))
+ w.logger.Error("Error while rendering command payload to JSON", zap.Object("msg", msg), zap.Error(err))
return
}
if len(payload) == 2 {
// This is an empty JSON message. We can leave it out.
+ w.logger.Debug("Empty payload, sending only command type", zap.String("type", string(msg.ServerType())))
_, err = fmt.Fprintf(writer, "%s!", msg.ServerType())
if err != nil {
- w.logger.Error("error while writing command-only message", zap.Error(err))
+ w.logger.Error("Error while writing no-payload command", zap.Error(err), zap.Object("msg", msg))
}
} else {
// Because we need to send this, we put in a space instead of an exclamation mark.
+ w.logger.Debug("Sending command with payload", zap.String("type", string(msg.ServerType())), zap.ByteString("payload", payload))
_, err = fmt.Fprintf(writer, "%s %s", msg.ServerType(), payload)
if err != nil {
- w.logger.Error("error while writing command-only message", zap.Error(err))
+ w.logger.Error("Error while writing command with payload", zap.Error(err), zap.Object("msg", msg))
}
}
}
// sendClose sends a close message on the websocket connection, but does not actually close the connection.
-// It does, however, close the incoming message channel.
+// It does, however, close the incoming message channel to the writer.
func (w *writer) sendClose(msg SocketClosed) {
w.logger.Debug("Shutting down the writer channel")
close(w.channel)
@@ -105,14 +146,14 @@ func (w *writer) sendClose(msg SocketClosed) {
w.logger.Debug("Writing close message")
err := w.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(int(msg.Code), msg.Text), time.Now().Add(ControlTimeLimit))
if err != nil {
- w.logger.Error("Error while sending close", zap.Error(err))
+ w.logger.Warn("Error while sending close", zap.Error(err))
}
}
// sendPing sends a ping message on the websocket connection. The content is arbitrary.
func (w *writer) sendPing() {
w.logger.Debug("Sending ping")
- err := w.conn.WriteControl(websocket.PingMessage, []byte("are you still there?"), time.Now().Add(ControlTimeLimit))
+ err := w.conn.WriteControl(websocket.PingMessage, []byte(PingData), time.Now().Add(ControlTimeLimit))
if err != nil {
w.logger.Error("Error while sending ping", zap.Error(err))
}
@@ -124,7 +165,12 @@ func (w *writer) sendPing() {
// During this time, the writer ignores all other messages from the channel and sends no pings.
func (w *writer) gracefulShutdown() {
defer w.finalShutdown()
+ // If the ping timer is still running, stop it and then close it.
+ if w.isTimerTicking() && !w.timer.Stop() {
+ <-w.timer.C
+ }
w.timer = nil
+ w.nextPingAt = time.Time{}
w.logger.Debug("Waiting for all channels to shut down")
for {
if w.channel == nil && w.readNotifications == nil {