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 {