diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 840097b..25bacf1 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -29,7 +29,6 @@ jobs: cache: 'npm' cache-dependency-path: | package-lock.json - packages/bootstrap/package-lock.json packages/multiplayer-template/package-lock.json - name: Build and test @@ -45,6 +44,21 @@ jobs: run: npm run typecheck working-directory: ./packages/bootstrap + # Needs core's dist from the build step, the snapshot types come from there + - name: Typecheck cli + run: npm run typecheck + working-directory: ./packages/cli + + # Run the built file directly: npm ci runs before the build, so npm skipped + # linking the `mavonengine` bin (its target didn't exist yet) and npx would + # fall back to looking it up on the registry + - name: Smoke test cli binary + run: | + node dist/index.js --version + node dist/index.js --help + node dist/index.js dashboard --help + working-directory: ./packages/cli + - name: npm link core locally run: | npm link diff --git a/README.md b/README.md index acc0717..3de8648 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ rendering, physics, networking, animation, and debugging into a single cohesive ## Get Started ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` | URL | Description | diff --git a/eslint.config.mjs b/eslint.config.mjs index f7cf0ba..0381e62 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,13 +11,4 @@ export default antfu( { files: ['**/*.{ts,tsx}'], }, - { - /** - * Allow console logs in bootstrap package - */ - files: ['packages/bootstrap/**'], - rules: { - 'no-console': 'off', - }, - }, ) diff --git a/package-lock.json b/package-lock.json index 6a3a82f..db227d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/bootstrap", "packages/core", - "packages/editor" + "packages/editor", + "packages/cli" ], "devDependencies": { "@antfu/eslint-config": "8.0.0", @@ -1630,6 +1631,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mavonengine/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@mavonengine/core": { "resolved": "packages/core", "link": true @@ -1686,6 +1691,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@profullstack/hqtui": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.11.1.tgz", + "integrity": "sha512-c/UzTmiRDjdLZQEO/gdVinUKtx6Q0WBPdixIm90MBTvC3DqFPqpersRs0BIDPS7XVokCSr1DtELI91kj5J1kCg==", + "license": "MIT", + "bin": { + "hqtui": "bin/hqtui.mjs" + }, + "engines": { + "bun": ">=1.1", + "node": ">=22.6" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -10864,16 +10882,15 @@ }, "packages/bootstrap": { "name": "@mavonengine/create-bootstrap", - "version": "0.0.9", + "version": "0.1.0", "license": "MIT", "dependencies": { - "adm-zip": "^0.5.16" + "@mavonengine/cli": ">=0.0.1 <1" }, "bin": { "create-bootstrap": "dist/index.js" }, "devDependencies": { - "@types/adm-zip": "^0.5.7", "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", @@ -10895,9 +10912,64 @@ "node": ">=14.17" } }, + "packages/cli": { + "name": "@mavonengine/cli", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@profullstack/hqtui": "0.11.1", + "adm-zip": "^0.5.16" + }, + "bin": { + "mavonengine": "dist/index.js" + }, + "devDependencies": { + "@mavonengine/core": "*", + "@types/adm-zip": "^0.5.7", + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "^3.2.4", + "tsup": "^8.5.1", + "typescript": "5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=24" + } + }, + "packages/cli/node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "packages/cli/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/cli/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "packages/core": { "name": "@mavonengine/core", - "version": "0.0.20", + "version": "0.0.21", "license": "MIT", "devDependencies": { "@dimforge/rapier3d-compat": "0.18.2", @@ -10918,6 +10990,9 @@ "vite-plugin-glsl": "^1.4.1", "vitest": "^3.2.4" }, + "engines": { + "node": ">=24" + }, "peerDependencies": { "@dimforge/rapier3d-compat": "0.18.2", "@geckos.io/client": "^3.0.2", diff --git a/package.json b/package.json index 64dea82..898eca0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "workspaces": [ "packages/bootstrap", "packages/core", - "packages/editor" + "packages/editor", + "packages/cli" ], "devDependencies": { "@antfu/eslint-config": "8.0.0", diff --git a/packages/bootstrap/package-lock.json b/packages/bootstrap/package-lock.json deleted file mode 100644 index a015d56..0000000 --- a/packages/bootstrap/package-lock.json +++ /dev/null @@ -1,2897 +0,0 @@ -{ - "name": "@mavonengine/create-bootstrap", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@mavonengine/create-bootstrap", - "version": "0.0.1", - "dependencies": { - "adm-zip": "^0.5.16" - }, - "bin": { - "create-bootstrap": "dist/index.js", - "create-mavonengine-bootstrap": "dist/index.js" - }, - "devDependencies": { - "@types/adm-zip": "^0.5.7", - "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^3.2.4", - "tsup": "^8.5.1", - "typescript": "5.9.2", - "vitest": "^3.2.4" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/adm-zip": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", - "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/packages/bootstrap/package.json b/packages/bootstrap/package.json index 0326799..4417d84 100644 --- a/packages/bootstrap/package.json +++ b/packages/bootstrap/package.json @@ -1,8 +1,8 @@ { "name": "@mavonengine/create-bootstrap", "type": "module", - "version": "0.0.9", - "description": "Bootstrap a MavonEngine multiplayer project", + "version": "0.1.0", + "description": "Deprecated: use 'npx @mavonengine/cli create'. Forwards to it.", "license": "MIT", "bin": { "create-bootstrap": "./dist/index.js" @@ -21,10 +21,9 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "adm-zip": "^0.5.16" + "@mavonengine/cli": ">=0.0.1 <1" }, "devDependencies": { - "@types/adm-zip": "^0.5.7", "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.5.1", diff --git a/packages/bootstrap/src/config.ts b/packages/bootstrap/src/config.ts deleted file mode 100644 index d1e057c..0000000 --- a/packages/bootstrap/src/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const TAG_URL - = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.14-alpha.zip' - -export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' diff --git a/packages/bootstrap/src/create.ts b/packages/bootstrap/src/create.ts deleted file mode 100644 index 787b306..0000000 --- a/packages/bootstrap/src/create.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import process from 'node:process' -import { TAG_URL, TEMPLATE_SUBPATH } from './config.js' -import { downloadZip } from './downloader.js' -import { extractTemplate } from './extractor.js' -import { runCommand } from './runner.js' - -export interface CreateOptions { - projectName: string - targetDir?: string - skipInstall?: boolean - skipDev?: boolean -} - -export async function createProject({ - projectName, - targetDir, - skipInstall = false, - skipDev = false, -}: CreateOptions): Promise { - const projectDir = resolve(targetDir ?? process.cwd(), projectName) - - if (existsSync(projectDir)) { - throw new Error(`Directory "${projectDir}" already exists`) - } - - console.log(`\nCreating project "${projectName}"...`) - mkdirSync(projectDir, { recursive: true }) - - console.log(`Downloading template from ${TAG_URL}...`) - const zipPath = await downloadZip(TAG_URL) - - console.log('Extracting template...') - extractTemplate({ zipPath, templateSubpath: TEMPLATE_SUBPATH, destDir: projectDir }) - - const pkgJsonPath = join(projectDir, 'package.json') - if (existsSync(pkgJsonPath)) { - const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) - pkg.name = projectName - writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`) - } - - if (!skipInstall) { - console.log('Installing dependencies...') - runCommand('npm', ['install'], projectDir) - } - - if (!skipDev) { - console.log('Starting dev server...') - runCommand('npm', ['run', 'dev'], projectDir) - } - - return projectDir -} diff --git a/packages/bootstrap/src/downloader.ts b/packages/bootstrap/src/downloader.ts deleted file mode 100644 index f767bca..0000000 --- a/packages/bootstrap/src/downloader.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createWriteStream } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Readable } from 'node:stream' -import { pipeline } from 'node:stream/promises' - -export async function downloadZip(url: string): Promise { - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) - } - - const dest = join(tmpdir(), `mavonengine-bootstrap-${Date.now()}.zip`) - const fileStream = createWriteStream(dest) - - await pipeline(Readable.fromWeb(response.body as import('node:stream/web').ReadableStream), fileStream) - - return dest -} - -export function extractZipRootFolder(zipEntries: string[]): string { - const first = zipEntries.find(e => e.includes('/')) - if (!first) - throw new Error('Could not determine root folder in zip archive') - return first.split('/')[0] -} diff --git a/packages/bootstrap/src/extractor.ts b/packages/bootstrap/src/extractor.ts deleted file mode 100644 index 128b002..0000000 --- a/packages/bootstrap/src/extractor.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import AdmZip from 'adm-zip' - -export interface ExtractOptions { - zipPath: string - templateSubpath: string - destDir: string -} - -export function extractTemplate({ zipPath, templateSubpath, destDir }: ExtractOptions): void { - const zip = new AdmZip(zipPath) - const entries = zip.getEntries() - - if (entries.length === 0) { - throw new Error('Downloaded zip archive is empty') - } - - const rootFolder = entries[0].entryName.split('/')[0] - const templatePrefix = `${rootFolder}/${templateSubpath}/` - - const templateEntries = entries.filter(e => e.entryName.startsWith(templatePrefix)) - - if (templateEntries.length === 0) { - throw new Error( - `Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`, - ) - } - - for (const entry of templateEntries) { - const relativePath = entry.entryName.slice(templatePrefix.length) - if (!relativePath) - continue - - const outputPath = join(destDir, relativePath) - - if (entry.isDirectory) { - mkdirSync(outputPath, { recursive: true }) - } - else { - mkdirSync(dirname(outputPath), { recursive: true }) - writeFileSync(outputPath, entry.getData()) - } - } -} diff --git a/packages/bootstrap/src/index.ts b/packages/bootstrap/src/index.ts index 5b102a7..edaf091 100644 --- a/packages/bootstrap/src/index.ts +++ b/packages/bootstrap/src/index.ts @@ -1,34 +1,17 @@ import process from 'node:process' -import { createProject } from './create.js' -import { promptProjectName } from './prompt.js' +import { DEPRECATION_NOTICE, isSupportedNode, MIN_NODE_MAJOR, runCreate } from './wrapper.js' -const PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/ +// Deprecated: everything happens in `mavonengine create` now, this only forwards to it +process.stderr.write(DEPRECATION_NOTICE) -async function main(): Promise { - let projectName = process.argv[2]?.trim() - - if (!projectName) { - projectName = await promptProjectName() - } - - if (!projectName) { - console.error('Error: project name is required') - process.exit(1) - } - - if (!PROJECT_NAME_RE.test(projectName)) { - console.error('Error: invalid project name') - process.exit(1) - } - - try { - const dir = await createProject({ projectName }) - console.log(`\nDone! Your project is ready at ${dir}`) - } - catch (err) { - console.error(`\nError: ${(err as Error).message}`) - process.exit(1) - } +if (!isSupportedNode(process.version)) { + process.stderr.write(`@mavonengine/cli needs Node ${MIN_NODE_MAJOR} or newer, this is ${process.version}\n`) + process.exit(1) } -main() +runCreate(process.argv.slice(2)) + .then(code => process.exit(code)) + .catch((err: Error) => { + process.stderr.write(`create-bootstrap: could not start @mavonengine/cli: ${err.message}\n`) + process.exit(1) + }) diff --git a/packages/bootstrap/src/prompt.ts b/packages/bootstrap/src/prompt.ts deleted file mode 100644 index 882532e..0000000 --- a/packages/bootstrap/src/prompt.ts +++ /dev/null @@ -1,13 +0,0 @@ -import process from 'node:process' -import { createInterface } from 'node:readline' - -export function promptProjectName(): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }) - - return new Promise((resolve) => { - rl.question('Project name: ', (answer) => { - rl.close() - resolve(answer.trim()) - }) - }) -} diff --git a/packages/bootstrap/src/runner.ts b/packages/bootstrap/src/runner.ts deleted file mode 100644 index 8e1e4df..0000000 --- a/packages/bootstrap/src/runner.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { spawnSync } from 'node:child_process' -import process from 'node:process' - -export function runCommand(command: string, args: string[], cwd: string): void { - const result = spawnSync(command, args, { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - }) - - if (result.error) { - throw result.error - } - - if (result.status !== 0) { - throw new Error(`Command "${command} ${args.join(' ')}" exited with code ${result.status}`) - } -} diff --git a/packages/bootstrap/src/wrapper.ts b/packages/bootstrap/src/wrapper.ts new file mode 100644 index 0000000..de00297 --- /dev/null +++ b/packages/bootstrap/src/wrapper.ts @@ -0,0 +1,60 @@ +import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { constants } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' + +/** @mavonengine/cli needs this Node major or newer */ +export const MIN_NODE_MAJOR = 24 + +const VERSION_PREFIX_RE = /^v/ + +export const DEPRECATION_NOTICE = 'create-bootstrap is deprecated, use \'npx @mavonengine/cli create\' instead\n' + +export function isSupportedNode(version: string): boolean { + return Number(version.replace(VERSION_PREFIX_RE, '').split('.')[0]) >= MIN_NODE_MAJOR +} + +/** + * Path of the `mavonengine` bin of the installed @mavonengine/cli. + */ +export function cliBinPath(from: string = import.meta.url): string { + const pkgPath = createRequire(from).resolve('@mavonengine/cli/package.json') + const { bin } = JSON.parse(readFileSync(pkgPath, 'utf8')) + return join(dirname(pkgPath), typeof bin === 'string' ? bin : bin.mavonengine) +} + +/** + * Run `mavonengine create` with these arguments on this terminal, so it can show its dashboard. + * Resolves with its exit code. + */ +export function runCreate(args: string[]): Promise { + const child = spawn(process.execPath, [cliBinPath(), 'create', ...args], { stdio: 'inherit' }) + + // Ctrl-C reaches the cli through the terminal's process group, so wait for it to exit. + // Signals sent to us alone (kill, closing the terminal) are passed on. + const onInterrupt = () => {} + const forward = (signal: NodeJS.Signals) => child.kill(signal) + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', forward) + process.on('SIGHUP', forward) + + return new Promise((resolve, reject) => { + const cleanup = () => { + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', forward) + process.off('SIGHUP', forward) + } + + child.on('error', (err) => { + cleanup() + reject(err) + }) + + child.on('exit', (code, signal) => { + cleanup() + resolve(code ?? 128 + (signal ? constants.signals[signal] : 0)) + }) + }) +} diff --git a/packages/bootstrap/tests/create.test.ts b/packages/bootstrap/tests/create.test.ts deleted file mode 100644 index e9b102f..0000000 --- a/packages/bootstrap/tests/create.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Buffer } from 'node:buffer' -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import AdmZip from 'adm-zip' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TAG_URL } from '../src/config.ts' - -import { createProject } from '../src/create.ts' -import { downloadZip } from '../src/downloader.ts' -import { runCommand } from '../src/runner.ts' - -vi.mock('../src/downloader.ts', () => ({ - downloadZip: vi.fn(), -})) - -vi.mock('../src/runner.ts', () => ({ - runCommand: vi.fn(), -})) - -function createFakeZip(destPath: string): void { - const zip = new AdmZip() - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/package.json', - Buffer.from( - JSON.stringify({ name: '@mavonengine/multiplayer-template', version: '0.0.1' }, null, 2), - ), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.writeZip(destPath) -} - -function createFakeZipWithoutPackageJson(destPath: string): void { - const zip = new AdmZip() - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.writeZip(destPath) -} - -describe('createProject', () => { - let tmpDir: string - let fakeZipPath: string - - beforeEach(() => { - tmpDir = join(tmpdir(), `mavon-create-test-${Date.now()}`) - mkdirSync(tmpDir, { recursive: true }) - fakeZipPath = join(tmpDir, 'fake.zip') - createFakeZip(fakeZipPath) - - vi.mocked(downloadZip).mockResolvedValue(fakeZipPath) - vi.mocked(runCommand).mockReturnValue(undefined) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('creates the project directory', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(tmpDir, 'my-game'))).toBe(true) - }) - - it('extracts template files into project directory', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(tmpDir, 'my-game', 'src', 'index.ts'))).toBe(true) - }) - - it('renames package.json name to the project name', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - const pkg = JSON.parse(readFileSync(join(tmpDir, 'my-game', 'package.json'), 'utf-8')) - expect(pkg.name).toBe('my-game') - }) - - it('runs npm install', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(runCommand).toHaveBeenCalledWith('npm', ['install'], join(tmpDir, 'my-game')) - }) - - it('runs npm run dev after install', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir }) - expect(runCommand).toHaveBeenCalledWith('npm', ['run', 'dev'], join(tmpDir, 'my-game')) - }) - - it('skips npm install when skipInstall is true', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipInstall: true, skipDev: true }) - expect(runCommand).not.toHaveBeenCalledWith('npm', ['install'], expect.any(String)) - }) - - it('still runs npm run dev when only skipInstall is true', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipInstall: true }) - expect(runCommand).not.toHaveBeenCalledWith('npm', ['install'], expect.any(String)) - expect(runCommand).toHaveBeenCalledWith('npm', ['run', 'dev'], join(tmpDir, 'my-game')) - }) - - it('throws if project directory already exists', async () => { - mkdirSync(join(tmpDir, 'existing-project')) - await expect( - createProject({ projectName: 'existing-project', targetDir: tmpDir, skipDev: true }), - ).rejects.toThrow('already exists') - }) - - it('returns the absolute path to the created project', async () => { - const dir = await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(dir).toBe(join(tmpDir, 'my-game')) - }) - - it('downloads from the configured TAG_URL', async () => { - await createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }) - expect(downloadZip).toHaveBeenCalledWith(TAG_URL) - }) - - it('does not throw when template has no package.json', async () => { - const zipWithoutPkg = join(tmpDir, 'no-pkg.zip') - createFakeZipWithoutPackageJson(zipWithoutPkg) - vi.mocked(downloadZip).mockResolvedValue(zipWithoutPkg) - - const dir = await createProject({ projectName: 'no-pkg-game', targetDir: tmpDir, skipDev: true }) - expect(existsSync(join(dir, 'src', 'index.ts'))).toBe(true) - expect(existsSync(join(dir, 'package.json'))).toBe(false) - }) - - it('propagates error when npm install fails', async () => { - vi.mocked(runCommand).mockImplementation((cmd, args) => { - if (cmd === 'npm' && args[0] === 'install') { - throw new Error('npm install failed') - } - }) - - await expect( - createProject({ projectName: 'my-game', targetDir: tmpDir, skipDev: true }), - ).rejects.toThrow('npm install failed') - }) - - it('propagates error when npm run dev fails', async () => { - vi.mocked(runCommand).mockImplementation((cmd, args) => { - if (cmd === 'npm' && args[0] === 'run') { - throw new Error('dev server failed') - } - }) - - await expect( - createProject({ projectName: 'my-game', targetDir: tmpDir }), - ).rejects.toThrow('dev server failed') - }) -}) diff --git a/packages/bootstrap/tests/downloader.test.ts b/packages/bootstrap/tests/downloader.test.ts deleted file mode 100644 index 738219d..0000000 --- a/packages/bootstrap/tests/downloader.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { downloadZip, extractZipRootFolder } from '../src/downloader.ts' - -describe('extractZipRootFolder', () => { - it('returns the root folder name from a list of zip entries', () => { - const entries = ['Core-0.0.6-alpha/', 'Core-0.0.6-alpha/README.md', 'Core-0.0.6-alpha/src/'] - expect(extractZipRootFolder(entries)).toBe('Core-0.0.6-alpha') - }) - - it('throws if no entry contains a slash', () => { - expect(() => extractZipRootFolder(['README.md'])).toThrow( - 'Could not determine root folder in zip archive', - ) - }) - - it('throws on empty entry list', () => { - expect(() => extractZipRootFolder([])).toThrow( - 'Could not determine root folder in zip archive', - ) - }) -}) - -describe('downloadZip', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('throws when the server returns a non-ok response', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', - } as Response) - - await expect(downloadZip('https://example.com/fake.zip')).rejects.toThrow('404') - }) - - it('throws when the server returns a 500 error', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - } as Response) - - await expect(downloadZip('https://example.com/fake.zip')).rejects.toThrow('500') - }) -}) diff --git a/packages/bootstrap/tests/extractor.test.ts b/packages/bootstrap/tests/extractor.test.ts deleted file mode 100644 index 80fe8a3..0000000 --- a/packages/bootstrap/tests/extractor.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Buffer } from 'node:buffer' -import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import AdmZip from 'adm-zip' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { extractTemplate } from '../src/extractor.ts' - -function createTestZip(destPath: string): void { - const zip = new AdmZip() - - zip.addFile('Core-0.0.6-alpha/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/README.md', Buffer.from('# Core')) - zip.addFile('Core-0.0.6-alpha/packages/', Buffer.alloc(0)) - zip.addFile('Core-0.0.6-alpha/packages/multiplayer-template/', Buffer.alloc(0)) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/package.json', - Buffer.from(JSON.stringify({ name: '@mavonengine/multiplayer-template', version: '0.0.1' })), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/', - Buffer.alloc(0), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/index.ts', - Buffer.from('export {}'), - ) - zip.addFile( - 'Core-0.0.6-alpha/packages/multiplayer-template/src/deeply/nested/util.ts', - Buffer.from('export const x = 1'), - ) - - zip.writeZip(destPath) -} - -describe('extractTemplate', () => { - let tmpDir: string - let zipPath: string - let destDir: string - - beforeEach(() => { - tmpDir = join(tmpdir(), `mavon-extractor-test-${Date.now()}`) - mkdirSync(tmpDir, { recursive: true }) - zipPath = join(tmpDir, 'test.zip') - destDir = join(tmpDir, 'output') - mkdirSync(destDir, { recursive: true }) - createTestZip(zipPath) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('extracts only the template subpath contents into destDir', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - expect(existsSync(join(destDir, 'package.json'))).toBe(true) - expect(existsSync(join(destDir, 'src', 'index.ts'))).toBe(true) - }) - - it('does not extract files outside the template path', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - expect(existsSync(join(destDir, 'README.md'))).toBe(false) - }) - - it('preserves file contents', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - const content = readFileSync(join(destDir, 'src', 'index.ts'), 'utf-8') - expect(content).toBe('export {}') - }) - - it('creates deeply nested directories and files', () => { - extractTemplate({ - zipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }) - - const content = readFileSync(join(destDir, 'src', 'deeply', 'nested', 'util.ts'), 'utf-8') - expect(content).toBe('export const x = 1') - }) - - it('throws when template subpath is not found in zip', () => { - expect(() => - extractTemplate({ - zipPath, - templateSubpath: 'packages/nonexistent', - destDir, - }), - ).toThrow('not found in zip archive') - }) - - it('throws when zip is empty', () => { - const emptyZip = new AdmZip() - const emptyZipPath = join(tmpDir, 'empty.zip') - emptyZip.writeZip(emptyZipPath) - - expect(() => - extractTemplate({ - zipPath: emptyZipPath, - templateSubpath: 'packages/multiplayer-template', - destDir, - }), - ).toThrow('empty') - }) -}) diff --git a/packages/bootstrap/tests/wrapper.test.ts b/packages/bootstrap/tests/wrapper.test.ts new file mode 100644 index 0000000..d0477b0 --- /dev/null +++ b/packages/bootstrap/tests/wrapper.test.ts @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { cliBinPath, isSupportedNode } from '../src/wrapper.ts' + +describe('isSupportedNode', () => { + it.each([ + ['v24.0.0', true], + ['v25.1.2', true], + ['v22.11.0', false], + ['v18.20.4', false], + ])('%s -> %s', (version, expected) => { + expect(isSupportedNode(version)).toBe(expected) + }) +}) + +describe('cliBinPath', () => { + it('points at the mavonengine bin of @mavonengine/cli', () => { + const bin = cliBinPath() + + expect(bin.replaceAll('\\', '/')).toMatch(/cli\/dist\/index\.js$/) + expect(existsSync(bin.replace(/dist[/\\]index\.js$/, 'package.json'))).toBe(true) + }) +}) diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..3c9d11c --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,107 @@ +# @mavonengine/cli + +Command line tools for [MavonEngine](https://mavonengine.com). + +Requires Node 24 or newer. + +```sh +npm install -D @mavonengine/cli +npx mavonengine --help +``` + +## `mavonengine create` + +Creates a new project from the multiplayer template, installs its dependencies and starts it like [`mavonengine dev`](#mavonengine-dev). It replaces `@mavonengine/create-bootstrap`, which now forwards to it. + +```sh +npx @mavonengine/cli create my-game +``` + +Asks for a name if none is given. While the template downloads and `npm install` runs, the startup screen shows each step and its output. Then the dev script starts, the dashboard takes over as soon as the game server answers, and the game opens in the browser. Quitting during setup stops it and leaves what was created so far in the project directory. Without an interactive terminal, or with `--no-dashboard`, the progress and the dev script's output go straight to the terminal. + +| Option | Default | | +| --- | --- | --- | +| `--cwd ` | current directory | Where to create the project directory | +| `--no-dashboard` | | Print the progress instead of showing the dashboard | +| `--port ` | `8050` | Game server HTTP port for the dashboard | +| `--theme ` | `dark` | Dashboard theme | +| `--transparent` | | Let a transparent terminal background show through | +| `--no-open` | | Don't open the game in the browser once the server is up | + +## `mavonengine dev` + +Runs the `dev` script of the MavonEngine project you are in and shows the [dashboard](#mavonengine-dashboard) for it. For the multiplayer template that starts the game server and the client together. + +While the game server starts, a startup screen shows the latest output of the dev script; the dashboard takes over as soon as the server answers. Once the server is up, the game it serves opens in your default browser (not over SSH, and only if the server answers `/` with a page); `--no-open` turns that off, and `o` opens it again any time. Quitting the dashboard (`q` or Ctrl-C) stops the dev script and everything it started. If the dev script exits on its own, the dashboard closes and its output is printed. + +```sh +npx mavonengine dev + +# From somewhere else +npx mavonengine dev --cwd path/to/my-game +``` + +The project root is the nearest `package.json`, from the current directory upwards, that depends on `@mavonengine/core` and has a `dev` script, so it also works from inside `client/` or `server/`. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn` or `bun`, default `npm`). Without an interactive terminal, or with `--no-dashboard`, the dev script's output goes straight to the terminal instead. Ctrl-C stops everything, and the command exits with the dev script's exit code. + +| Option | Default | | +| --- | --- | --- | +| `--cwd ` | current directory | Where to start looking for the project | +| `--no-dashboard` | | Only run the dev script | +| `--port ` | `8050` | Game server HTTP port for the dashboard | +| `--theme ` | `dark` | Dashboard theme | +| `--transparent` | | Let a transparent terminal background show through | +| `--no-open` | | Don't open the game in the browser once the server is up | + +## `mavonengine dashboard` + +A live terminal dashboard for a running game server, built with [hqtui](https://github.com/profullstack/hqtui). + +It shows CPU, network bandwidth, tick timings against the tick budget, world/physics/memory stats, the connected players, the server's recent log output and the HTTP requests it received. It reads them from the server's `GET /api/game/metrics`, `GET /api/game/logs` and `GET /api/game/requests` endpoints, which need a `@mavonengine/core` release that includes them. + +```sh +# Local server on the default HTTP port (8050) +npx mavonengine dashboard + +# Remote server with a metrics token +MAVONENGINE_METRICS_TOKEN=secret npx mavonengine dashboard --host game.example.com --port 8050 + +# No server needed: simulated data +npx mavonengine dashboard --sim +``` + +| Option | Default | | +| --- | --- | --- | +| `--host ` | `localhost` | Server host | +| `--port ` | `8050` | Server HTTP port (`ServerOptions.httpPort`) | +| `--url ` | | Full metrics URL, overrides `--host` and `--port` | +| `--token ` | `$MAVONENGINE_METRICS_TOKEN` | Metrics token (`ServerOptions.metrics.token`) | +| `--interval ` | `1000` | Poll interval | +| `--theme ` | `dark` | `dark`, `dracula`, `nord`, `tokyoNight`, `gruvbox`, `matrix`, `monochrome`, `highContrast`, `light` | +| `--transparent` | | Let a transparent terminal background show through. Text can be hard to read over a light wallpaper | +| `--sim` | | Simulated data instead of a server | + +Keys: `q` quits, `r` refreshes now, `o` opens the game the server serves in the browser, `1`–`4` switch between the Overview, Players, Logs and HTTP tabs. The mouse wheel scrolls the players table, the logs and the requests; scroll the logs back to the bottom, or the requests back to the top, to follow new ones again. + +The HTTP tab lists every request the game server's HTTP server received, newest first: the client (static files or Vite in dev), your own `onHttp` routes, the geckos.io signaling requests and WebSocket upgrades such as Vite's HMR socket, each with status, duration, size and client address. Query strings are left out since they can hold tokens, and the dashboard's own polling is not recorded. The game traffic itself is WebRTC, not HTTP, so it shows on the Players tab instead; with a separate `socketPort` the signaling requests go to that port and don't show up either. + +### Server access + +The metrics endpoint is configured on the game server: + +```ts +const server = new MyServer(logger, physicsWorld, { + httpPort: 8050, + metrics: { + token: process.env.METRICS_TOKEN, + // Recent log entries kept for the dashboard, 0 turns the logs endpoint off + logBufferSize: 500, + // Recent HTTP requests kept for the dashboard, 0 turns the requests endpoint off + requestLogSize: 500, + }, +}) +``` + +- With no token, the endpoint only answers requests from localhost. +- With `NODE_ENV=production` and no token, the endpoint is turned off. +- Set `metrics.enabled: false` to turn it off everywhere. +- The logs endpoint follows the same rules. It serves whatever passes your logger's level. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..3a24ab8 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,47 @@ +{ + "name": "@mavonengine/cli", + "type": "module", + "version": "0.0.1", + "description": "MavonEngine command line tools", + "license": "MIT", + "keywords": [ + "mavonengine", + "cli", + "create", + "dashboard", + "game-server", + "monitoring" + ], + "bin": { + "mavonengine": "./dist/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc -p tsconfig.check.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "@profullstack/hqtui": "0.11.1", + "adm-zip": "^0.5.16" + }, + "devDependencies": { + "@mavonengine/core": "*", + "@types/adm-zip": "^0.5.7", + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "^3.2.4", + "tsup": "^8.5.1", + "typescript": "5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts new file mode 100644 index 0000000..970abd2 --- /dev/null +++ b/packages/cli/src/browser.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process' +import process from 'node:process' + +const AMPERSAND_RE = /&/g + +/** + * The command that opens `url` in the default browser on `platform`. + */ +export function openCommand(url: string, platform: NodeJS.Platform = process.platform): [string, string[]] { + if (platform === 'darwin') + return ['open', [url]] + + // `start` treats its first quoted argument as the window title, and & ends a cmd command + if (platform === 'win32') + return ['cmd', ['/c', 'start', '""', url.replace(AMPERSAND_RE, '^&')]] + + return ['xdg-open', [url]] +} + +/** + * Open `url` in the default browser, without waiting for it. Failing to is not an error worth stopping for. + */ +export function openBrowser(url: string) { + const [command, args] = openCommand(url) + + try { + const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true }) + child.on('error', () => {}) + child.unref() + } + catch { + // No opener on this system + } +} + +/** + * Whether the browser would open on a screen in front of the user, rather than on the far end of an SSH session. + */ +export function canOpenBrowserHere(env: Record = process.env) { + return !env.SSH_CONNECTION && !env.SSH_TTY +} + +/** + * Whether `url` answers with a page, so a server that doesn't serve the client isn't opened as a 404. + */ +export async function servesPage(url: string, { timeoutMs = 3000, fetch = globalThis.fetch } = {}): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) + await res.body?.cancel() + return res.ok && (res.headers.get('content-type') ?? '').includes('text/html') + } + catch { + return false + } +} diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts new file mode 100644 index 0000000..0ef659d --- /dev/null +++ b/packages/cli/src/commands/create.ts @@ -0,0 +1,198 @@ +import type { CreateReporter } from '../create/project' +import type { MetricsSource } from '../dashboard/MetricsClient' +import type { DashboardOptions } from './dashboard' +import type { DevOptions, DevProcess } from './dev' +import { existsSync } from 'node:fs' +import process from 'node:process' +import { createInterface } from 'node:readline/promises' +import { parseArgs } from 'node:util' +import { createProject, PROJECT_NAME_RE, projectDirFor } from '../create/project' +import { MetricsSourceError } from '../dashboard/MetricsClient' +import { runDashboard, UsageError } from './dashboard' +import { DEV_FLAGS, installedEngineVersion, runDev, startDev, toDevOptions } from './dev' + +export const CREATE_HELP = `Usage: mavonengine create [name] [options] + +Create a MavonEngine project from the multiplayer template, install its +dependencies and start it like 'mavonengine dev'. Asks for a name if none is given. + +Options: + --cwd Create the project in here (default: current directory) + --no-dashboard Print the progress and the dev script's output instead + --port Game server HTTP port for the dashboard (default: 8050) + --theme Dashboard theme, see 'mavonengine dashboard --help' + --transparent Let a transparent terminal background show through + --no-open Don't open the game in the browser once the server is up + -h, --help Show this help +` + +export interface CreateCommandOptions extends DevOptions { + /** Asked for when missing */ + projectName?: string +} + +/** Bytes of setup output kept for the startup screen, printed if setup fails */ +const MAX_SETUP_OUTPUT = 64 * 1024 + +/** + * Turn CLI arguments into create options. Throws UsageError on bad input. + */ +export function parseCreateArgs(args: string[], cwd = process.cwd(), env: Record = process.env): CreateCommandOptions | 'help' { + const { values, positionals } = parseArgs({ + args, + options: DEV_FLAGS, + strict: true, + allowPositionals: true, + allowNegative: true, + }) + + if (values.help) + return 'help' + + if (positionals.length > 1) + throw new UsageError(`expected one project name, got ${positionals.length}`) + + const projectName = positionals[0]?.trim() || undefined + if (projectName) + checkProjectName(projectName) + + return { ...toDevOptions(values, cwd, env), projectName } +} + +function checkProjectName(name: string) { + if (!PROJECT_NAME_RE.test(name)) + throw new UsageError(`invalid project name '${name}'`) +} + +async function promptProjectName(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + return (await rl.question('Project name: ')).trim() + } + finally { + rl.close() + } +} + +/** + * A metrics source that reports 'connecting' until `ready()`, so the startup screen stays up + * while the project is set up, even if something else already answers on the port. + */ +export function gatedSource(source: MetricsSource, ready: () => boolean): MetricsSource { + return { + ...source, + fetch: () => ready() ? source.fetch() : Promise.reject(new MetricsSourceError('Creating the project', 'connecting')), + } +} + +/** + * Create the project on the startup screen, then run its dev script behind the dashboard. + * Quitting stops whatever is running. A failed step closes the dashboard and prints the output. + */ +export async function runCreateWithDashboard(projectName: string, parentDir: string, dashboard: DashboardOptions): Promise { + let label = 'Creating the project…' + let output = '' + let engineVersion: string | undefined + let dev: DevProcess | undefined + + const report: CreateReporter = { + step: (text) => { + label = text + }, + output: (chunk) => { + output = (output + chunk).slice(-MAX_SETUP_OUTPUT) + }, + } + + // Aborted by the user quitting, stops the download or install + const setup = new AbortController() + // Aborted when setup fails or the dev script exits, closes the dashboard + const done = new AbortController() + + const work = (async () => { + const dir = await createProject({ projectName, parentDir, report, signal: setup.signal }) + setup.signal.throwIfAborted() + + engineVersion = installedEngineVersion(dir) + label = 'Starting the game server…' + dev = startDev(dir, { capture: true }) + return dev.exited + })() + work.then(() => done.abort(), () => done.abort()) + + const startup = () => dev + ? { engineVersion, output: dev.output() } + : { engineVersion, label, output, outputTitle: 'Setup' } + + let quit = false + try { + await runDashboard({ ...dashboard, source: gatedSource(dashboard.source, () => dev !== undefined), startup }, done.signal) + } + finally { + quit = !done.signal.aborted + if (quit) { + setup.abort() + await dev?.stop().catch(() => {}) + } + } + + if (quit) { + if (dev) + return 0 + + await work.catch(() => {}) + process.stderr.write(`mavonengine: stopped creating the project, what was set up so far is in ${projectDirFor(projectName, parentDir)}\n`) + return 130 + } + + try { + const exitCode = await work + process.stderr.write(`${dev!.output()}\nmavonengine: the dev script exited with code ${exitCode}\n`) + return exitCode + } + catch (err) { + // Rejects when setup failed or the package manager could not be started + process.stderr.write(output) + throw err + } +} + +/** + * Create the project with progress in the terminal, then run its dev script there. + */ +async function runCreatePlain(projectName: string, parentDir: string): Promise { + const report: CreateReporter = { + step: () => {}, + output: chunk => process.stdout.write(chunk), + } + + const dir = await createProject({ projectName, parentDir, report }) + process.stdout.write(`\nmavonengine: your project is ready at ${dir}\n`) + return runDev(dir) +} + +export async function runCreateCommand(options: CreateCommandOptions): Promise { + const interactive = !!process.stdout.isTTY && !!process.stdin.isTTY + + let projectName = options.projectName + if (!projectName) { + if (!interactive) + throw new UsageError('a project name is required, e.g. mavonengine create my-game') + + projectName = await promptProjectName() + if (!projectName) + throw new UsageError('a project name is required') + checkProjectName(projectName) + } + + // Checked again by createProject, but failing here doesn't flash the dashboard + const dir = projectDirFor(projectName, options.cwd) + if (existsSync(dir)) + throw new UsageError(`${dir} already exists`) + + // The dashboard needs an interactive terminal; without one (CI, piped output) progress goes to the terminal + if (options.dashboard && interactive) + return runCreateWithDashboard(projectName, options.cwd, options.dashboard) + + return runCreatePlain(projectName, options.cwd) +} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts new file mode 100644 index 0000000..2439db5 --- /dev/null +++ b/packages/cli/src/commands/dashboard.ts @@ -0,0 +1,162 @@ +import type { MetricsSource } from '../dashboard/MetricsClient' +import type { StartupInfo, ViewState } from '../dashboard/view' +import process from 'node:process' +import { parseArgs } from 'node:util' +import { openBrowser, servesPage } from '../browser' +import MetricsClient, { httpSource } from '../dashboard/MetricsClient' +import { simulatedSource } from '../dashboard/simulator' +import { readableTheme } from '../dashboard/theme' +import { handlePlayersKey, renderDashboard, tabForKey } from '../dashboard/view' + +export const DASHBOARD_HELP = `Usage: mavonengine dashboard [options] + +Live terminal dashboard for a running MavonEngine game server. + +Options: + --host Server host (default: localhost) + --port Server HTTP port (default: 8050) + --url Full metrics URL, overrides --host and --port + --token Metrics token (or set MAVONENGINE_METRICS_TOKEN) + --interval Poll interval in ms (default: 1000) + --theme dark, dracula, nord, tokyoNight, gruvbox, matrix, light, … + --transparent Let a transparent terminal background show through + (text can be hard to read over a light wallpaper) + --sim Show simulated data instead of connecting to a server + -h, --help Show this help +` + +export interface DashboardOptions { + source: MetricsSource + intervalMs: number + theme?: string + transparent: boolean + /** Page the game server serves the client on, opened with `o` */ + clientUrl?: string + /** Open `clientUrl` in the browser once the server first answers, if it serves a page there */ + openBrowser?: boolean + /** Show the startup screen until the server first answers (`mavonengine dev`), read every frame */ + startup?(): StartupInfo +} + +/** Poll interval while waiting for a server that is still starting, so the dashboard appears as soon as it is up */ +const STARTUP_POLL_MS = 250 + +export class UsageError extends Error { } + +/** + * Turn CLI arguments into dashboard options. Throws UsageError on bad input. + */ +export function parseDashboardArgs(args: string[], env: Record = process.env): DashboardOptions | 'help' { + const { values } = parseArgs({ + args, + options: { + host: { type: 'string', default: 'localhost' }, + port: { type: 'string', default: '8050' }, + url: { type: 'string' }, + token: { type: 'string' }, + interval: { type: 'string', default: '1000' }, + theme: { type: 'string' }, + transparent: { type: 'boolean', default: false }, + sim: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + strict: true, + allowPositionals: false, + }) + + if (values.help) + return 'help' + + const intervalMs = Number(values.interval) + if (!Number.isInteger(intervalMs) || intervalMs < 100) + throw new UsageError('--interval must be a whole number of ms, at least 100') + + if (values.sim) + return { source: simulatedSource(), intervalMs, theme: values.theme, transparent: values.transparent } + + let url = values.url + if (!url) { + const port = Number(values.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new UsageError('--port must be between 1 and 65535') + + const host = values.host!.includes(':') && !values.host!.startsWith('[') ? `[${values.host}]` : values.host + url = `http://${host}:${port}/api/game/metrics` + } + + try { + void new URL(url) + } + catch { + throw new UsageError(`Invalid --url: ${url}`) + } + + const token = values.token ?? env.MAVONENGINE_METRICS_TOKEN + + return { source: httpSource({ url, token }), clientUrl: new URL('/', url).href, intervalMs, theme: values.theme, transparent: values.transparent } +} + +/** + * Run the dashboard until the user quits or `signal` aborts. + */ +export async function runDashboard(options: DashboardOptions, signal?: AbortSignal) { + // Loaded lazily so `mavonengine --help` doesn't pay for the TUI + const { createApp } = await import('@profullstack/hqtui') + + const client = new MetricsClient(options.source, { + intervalMs: options.intervalMs, + startupIntervalMs: options.startup ? STARTUP_POLL_MS : undefined, + }) + const view: ViewState = { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true } + + const app = await createApp({ fps: 15, theme: readableTheme(options.theme), paintBackground: !options.transparent }) + + app.render(({ ui, theme }) => renderDashboard(ui, theme, client.state, view, { + transparent: options.transparent, + startup: options.startup?.(), + openKey: !!options.clientUrl, + })) + + client.onChange(() => app.invalidate()) + + const { clientUrl } = options + if (clientUrl && options.openBrowser) { + const off = client.onChange(async (state) => { + if (state.status !== 'live') + return + + off() + if (await servesPage(clientUrl)) + openBrowser(clientUrl) + }) + } + + app.on('key', (event) => { + if (event.key === 'r') + void client.refresh() + + if (event.key === 'o' && options.clientUrl) + openBrowser(options.clientUrl) + + const tab = tabForKey(event.key) + if (tab !== null) { + view.tab = tab + app.invalidate() + } + + if (handlePlayersKey(view, client.state.snapshot, event.key)) + app.invalidate() + }) + + app.on('exit', () => client.stop()) + + const onAbort = () => app.stop() + signal?.addEventListener('abort', onAbort) + + client.start() + // stop() does nothing before start(), so an abort that already happened is checked here + if (!signal?.aborted) + await app.start() + signal?.removeEventListener('abort', onAbort) + client.stop() +} diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 0000000..3ef7d89 --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,366 @@ +import type { DashboardOptions } from './dashboard' +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { constants } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' +import { parseArgs } from 'node:util' +import { canOpenBrowserHere } from '../browser' +import { parseDashboardArgs, runDashboard, UsageError } from './dashboard' + +export const DEV_HELP = `Usage: mavonengine dev [options] + +Run the dev script of the MavonEngine project you are in (game server and client) +and show the dashboard for it. Once the game server is up, the game opens in the +browser. Quitting the dashboard stops the dev servers. + +Options: + --cwd Start looking for the project here (default: current directory) + --no-dashboard Only run the dev script, with its output in the terminal + --port Game server HTTP port for the dashboard (default: 8050) + --theme Dashboard theme, see 'mavonengine dashboard --help' + --transparent Let a transparent terminal background show through + --no-open Don't open the game in the browser once the server is up + -h, --help Show this help +` + +export interface DevOptions { + cwd: string + /** false with --no-dashboard */ + dashboard: DashboardOptions | false +} + +/** Bytes of dev script output kept while the dashboard is up, printed if the script exits on its own */ +const MAX_CAPTURED_OUTPUT = 64 * 1024 + +/** How long the dev processes get to exit after SIGTERM before they are killed */ +const STOP_TIMEOUT_MS = 3000 +/** How long killed processes get to disappear before stop() gives up waiting */ +const KILL_TIMEOUT_MS = 2000 + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' + +const LOCKFILES: [string, PackageManager][] = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['package-lock.json', 'npm'], +] + +const WHITESPACE_RE = /\s+/ + +/** Flags shared by `mavonengine dev` and `mavonengine create` */ +export const DEV_FLAGS = { + cwd: { type: 'string' }, + dashboard: { type: 'boolean', default: true }, + port: { type: 'string' }, + theme: { type: 'string' }, + transparent: { type: 'boolean', default: false }, + open: { type: 'boolean', default: true }, + help: { type: 'boolean', short: 'h', default: false }, +} as const + +type DevFlags = ReturnType>['values'] + +/** + * Turn CLI arguments into dev options. Throws UsageError on bad input. + */ +export function parseDevArgs(args: string[], cwd = process.cwd(), env: Record = process.env): DevOptions | 'help' { + const { values } = parseArgs({ + args, + options: DEV_FLAGS, + strict: true, + allowPositionals: false, + allowNegative: true, + }) + + if (values.help) + return 'help' + + return toDevOptions(values, cwd, env) +} + +export function toDevOptions(values: DevFlags, cwd: string, env: Record): DevOptions { + let dashboard: DashboardOptions | false = false + if (values.dashboard) { + const dashboardArgs = [ + ...(values.port ? ['--port', values.port] : []), + ...(values.theme ? ['--theme', values.theme] : []), + ...(values.transparent ? ['--transparent'] : []), + ] + dashboard = parseDashboardArgs(dashboardArgs, env) as DashboardOptions + // Over SSH the browser would open on the other machine + dashboard.openBrowser = values.open && canOpenBrowserHere(env) + } + + return { cwd: resolve(cwd, values.cwd ?? '.'), dashboard } +} + +function readPackageJson(dir: string): Record | undefined { + const path = join(dir, 'package.json') + if (!existsSync(path)) + return undefined + + try { + return JSON.parse(readFileSync(path, 'utf8')) + } + catch { + return undefined + } +} + +/** + * Walk up from `start` to the nearest package.json that depends on @mavonengine/core and has a dev script. + * Starting inside a workspace such as `client/` finds the project root above it. + */ +export function findProjectRoot(start: string): string | undefined { + for (let dir = resolve(start); ; dir = dirname(dir)) { + const pkg = readPackageJson(dir) + const deps = { ...pkg?.dependencies, ...pkg?.devDependencies } + if ('@mavonengine/core' in deps && typeof pkg?.scripts?.dev === 'string') + return dir + + if (dirname(dir) === dir) + return undefined + } +} + +/** + * The @mavonengine/core version installed in a project, if any. + */ +export function installedEngineVersion(root: string): string | undefined { + return readPackageJson(join(root, 'node_modules', '@mavonengine', 'core'))?.version +} + +export function detectPackageManager(root: string): PackageManager { + return LOCKFILES.find(([file]) => existsSync(join(root, file)))?.[1] ?? 'npm' +} + +/** + * Find every process below `root`, given `ps -A -o pid=,ppid=` output. + */ +export function descendants(root: number, psOutput: string): number[] { + const children = new Map() + for (const line of psOutput.trim().split('\n')) { + const [pid, ppid] = line.trim().split(WHITESPACE_RE).map(Number) + if (!children.has(ppid)) + children.set(ppid, []) + children.get(ppid)!.push(pid) + } + + const found: number[] = [] + const queue = [root] + while (queue.length) { + for (const child of children.get(queue.shift()!) ?? []) { + found.push(child) + queue.push(child) + } + } + return found +} + +/** + * A process and everything it started, parents first. Empty on Windows, where taskkill walks the tree itself. + */ +function processTree(pid: number): number[] { + if (process.platform === 'win32') + return [] + + try { + return [pid, ...descendants(pid, execFileSync('ps', ['-A', '-o', 'pid=,ppid='], { encoding: 'utf8' }))] + } + catch { + return [pid] + } +} + +function signalAll(pids: number[], signal: NodeJS.Signals) { + for (const p of pids) { + try { + process.kill(p, signal) + } + catch { + // Already gone + } + } +} + +function isAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } + catch { + return false + } +} + +async function waitForExit(pids: number[], timeoutMs: number) { + const deadline = Date.now() + timeoutMs + while (pids.some(isAlive) && Date.now() < deadline) + await delay(50) +} + +/** + * Signal a process and everything it started. npm exits on SIGTERM without passing it on, + * which would leave the game server and vite running. + */ +function killTree(pid: number, signal: NodeJS.Signals) { + if (process.platform === 'win32') { + try { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } + catch { + // Already gone + } + return + } + + signalAll(processTree(pid), signal) +} + +export interface DevProcess { + /** Resolves with the dev script's exit code */ + exited: Promise + /** SIGTERM the whole tree, SIGKILL whatever is left after STOP_TIMEOUT_MS, and wait for the script to exit */ + stop(): Promise + /** The captured output, when started with `capture` */ + output(): string +} + +/** + * Start the project's dev script. With `capture` its output is kept in memory instead of + * written to the terminal, which belongs to the dashboard then. + */ +export function startDev(root: string, { capture = false } = {}): DevProcess { + const pm = detectPackageManager(root) + + const child = spawn(pm, ['run', 'dev'], { + cwd: root, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + // npm, pnpm and yarn are .cmd shims on Windows + shell: process.platform === 'win32', + }) + + let output = '' + const keep = (chunk: string) => { + output = (output + chunk).slice(-MAX_CAPTURED_OUTPUT) + } + + // The terminal may already belong to the dashboard + const announce = `mavonengine: running '${pm} run dev' in ${root}\n` + if (capture) + keep(announce) + else + process.stdout.write(announce) + child.stdout?.setEncoding('utf8').on('data', keep) + child.stderr?.setEncoding('utf8').on('data', keep) + + let running = true + + // Ctrl-C reaches the child through the terminal's process group, so only keep ourselves alive until it exits. + // Signals sent to us alone (kill, closing the terminal) are passed on. + const onInterrupt = () => {} + const forward = (signal: NodeJS.Signals) => child.pid && killTree(child.pid, signal) + // Last resort when we exit before the dev script, e.g. the dashboard's own signal handlers calling process.exit + const onExit = () => running && child.pid && killTree(child.pid, 'SIGTERM') + process.on('SIGINT', onInterrupt) + process.on('SIGTERM', forward) + process.on('SIGHUP', forward) + process.on('exit', onExit) + + const exited = new Promise((resolvePromise, reject) => { + const cleanup = () => { + running = false + process.off('SIGINT', onInterrupt) + process.off('SIGTERM', forward) + process.off('SIGHUP', forward) + process.off('exit', onExit) + } + + child.on('error', (err: NodeJS.ErrnoException) => { + cleanup() + reject(err.code === 'ENOENT' ? new Error(`'${pm}' was not found on your PATH`) : err) + }) + + child.on('exit', (code, signal) => { + cleanup() + resolvePromise(code ?? 128 + (signal ? constants.signals[signal] : 0)) + }) + }) + + const stop = async () => { + if (running && child.pid) { + if (process.platform === 'win32') { + killTree(child.pid, 'SIGTERM') + } + else { + // Remembered up front: once npm exits, its children are reparented and can't be found from it anymore + const tree = processTree(child.pid) + signalAll(tree, 'SIGTERM') + await waitForExit(tree, STOP_TIMEOUT_MS) + + // SIGKILL takes effect asynchronously, so wait for those to be gone too + signalAll(tree.filter(isAlive), 'SIGKILL') + await waitForExit(tree, KILL_TIMEOUT_MS) + } + } + + return exited + } + + return { exited, stop, output: () => output } +} + +/** + * Run the project's dev script and resolve with its exit code. + */ +export function runDev(root: string): Promise { + return startDev(root).exited +} + +/** + * Run the dev script behind the dashboard. Quitting the dashboard stops the dev script; + * the dev script exiting on its own closes the dashboard and prints what it wrote. + */ +export async function runDevWithDashboard(root: string, dashboard: DashboardOptions): Promise { + const dev = startDev(root, { capture: true }) + const engineVersion = installedEngineVersion(root) + const startup = () => ({ engineVersion, output: dev.output() }) + + const devExited = new AbortController() + dev.exited.then(() => devExited.abort(), () => devExited.abort()) + + let quit = false + try { + await runDashboard({ ...dashboard, startup }, devExited.signal) + } + finally { + quit = !devExited.signal.aborted + if (quit) + await dev.stop().catch(() => {}) + } + + // The user quit, so stopping the dev servers is the expected outcome + if (quit) + return 0 + + // Rejects when the package manager could not be started + const exitCode = await dev.exited + process.stderr.write(`${dev.output()}\nmavonengine: the dev script exited with code ${exitCode}\n`) + return exitCode +} + +export async function runDevCommand(options: DevOptions): Promise { + const root = findProjectRoot(options.cwd) + if (!root) + throw new UsageError(`no MavonEngine project found in ${options.cwd} or its parents (looking for a package.json with @mavonengine/core and a dev script)`) + + // The dashboard needs an interactive terminal; without one (CI, piped output) just run the dev script + if (options.dashboard && process.stdout.isTTY && process.stdin.isTTY) + return runDevWithDashboard(root, options.dashboard) + + return runDev(root) +} diff --git a/packages/cli/src/create/project.ts b/packages/cli/src/create/project.ts new file mode 100644 index 0000000..992aedd --- /dev/null +++ b/packages/cli/src/create/project.ts @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process' +import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import process from 'node:process' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import AdmZip from 'adm-zip' +import { formatBytes } from '../dashboard/format' + +export const TEMPLATE_URL = 'https://github.com/MavonEngine/Core/archive/refs/tags/0.0.15-alpha.zip' +export const TEMPLATE_SUBPATH = 'packages/multiplayer-template' + +export const PROJECT_NAME_RE = /^[\w@./-][\w@./\- ]*$/ + +/** + * Where creating a project reports to: the startup screen, or the terminal without a dashboard. + */ +export interface CreateReporter { + /** What is happening right now, one line */ + step(label: string): void + /** Output of the commands that run, as it arrives */ + output(chunk: string): void +} + +export interface CreateProjectOptions { + projectName: string + /** The project is created in a new directory named after it in here */ + parentDir: string + report: CreateReporter + /** Stops the download or the install */ + signal?: AbortSignal + templateUrl?: string + fetch?: typeof globalThis.fetch + /** Install the dependencies, default true */ + install?: boolean +} + +export function projectDirFor(projectName: string, parentDir: string) { + return resolve(parentDir, projectName) +} + +/** + * Download `url` to a temporary file, reporting the bytes received. + */ +export async function downloadZip(url: string, { report, signal, fetch = globalThis.fetch }: Pick): Promise { + const response = await fetch(url, { signal }) + + if (!response.ok || !response.body) + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + + const total = Number(response.headers.get('content-length')) || 0 + let received = 0 + const progress = new Transform({ + transform(chunk: Uint8Array, _encoding, callback) { + received += chunk.length + report.step(`Downloading the template… ${formatBytes(received)}${total ? ` of ${formatBytes(total)}` : ''}`) + callback(null, chunk) + }, + }) + + const dest = join(tmpdir(), `mavonengine-create-${process.pid}-${Date.now()}.zip`) + try { + await pipeline(Readable.fromWeb(response.body as import('node:stream/web').ReadableStream), progress, createWriteStream(dest), { signal }) + } + catch (err) { + rmSync(dest, { force: true }) + throw err + } + + return dest +} + +/** + * Copy `templateSubpath` out of a GitHub archive (everything is under one root folder) into `destDir`. + * Returns the number of files written. + */ +export function extractTemplate(zipPath: string, templateSubpath: string, destDir: string): number { + const entries = new AdmZip(zipPath).getEntries() + + if (entries.length === 0) + throw new Error('Downloaded zip archive is empty') + + const rootFolder = entries[0].entryName.split('/')[0] + const templatePrefix = `${rootFolder}/${templateSubpath}/` + const templateEntries = entries.filter(e => e.entryName.startsWith(templatePrefix)) + + if (templateEntries.length === 0) + throw new Error(`Template path "${templateSubpath}" not found in zip archive (root: ${rootFolder})`) + + const root = resolve(destDir) + let files = 0 + for (const entry of templateEntries) { + const relativePath = entry.entryName.slice(templatePrefix.length) + if (!relativePath) + continue + + const outputPath = resolve(root, relativePath) + // A crafted archive could point outside the project with ../ + const inside = relative(root, outputPath) + if (inside.startsWith('..') || isAbsolute(inside)) + throw new Error(`Refusing to extract ${entry.entryName} outside the project`) + + if (entry.isDirectory) { + mkdirSync(outputPath, { recursive: true }) + } + else { + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, entry.getData()) + files++ + } + } + + return files +} + +/** + * Run a command with its output going to the reporter. Rejects on a non-zero exit. + */ +export function runCommand(command: string, args: string[], cwd: string, { report, signal }: Pick): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + // npm is a .cmd shim on Windows + shell: process.platform === 'win32', + signal, + }) + + child.stdout.setEncoding('utf8').on('data', report.output) + child.stderr.setEncoding('utf8').on('data', report.output) + + child.on('error', (err: NodeJS.ErrnoException) => { + reject(err.code === 'ENOENT' ? new Error(`'${command}' was not found on your PATH`) : err) + }) + + child.on('exit', (code, exitSignal) => { + if (code === 0) + resolvePromise() + else + reject(new Error(`'${command} ${args.join(' ')}' ${code === null ? `was stopped by ${exitSignal}` : `exited with code ${code}`}`)) + }) + }) +} + +/** + * Create a project from the multiplayer template: download, extract, name it and install its dependencies. + * Resolves with the project directory. + */ +export async function createProject({ projectName, parentDir, report, signal, templateUrl = TEMPLATE_URL, fetch, install = true }: CreateProjectOptions): Promise { + const projectDir = projectDirFor(projectName, parentDir) + + if (existsSync(projectDir)) + throw new Error(`Directory "${projectDir}" already exists`) + + report.output(`Creating project "${projectName}" in ${projectDir}\n`) + mkdirSync(projectDir, { recursive: true }) + + report.step('Downloading the template…') + report.output(`Downloading the template from ${templateUrl}\n`) + const zipPath = await downloadZip(templateUrl, { report, signal, fetch }) + + try { + report.step('Extracting the template…') + const files = extractTemplate(zipPath, TEMPLATE_SUBPATH, projectDir) + report.output(`Extracted ${files} files\n`) + } + finally { + rmSync(zipPath, { force: true }) + } + + const pkgJsonPath = join(projectDir, 'package.json') + if (existsSync(pkgJsonPath)) { + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) + pkg.name = projectName + writeFileSync(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`) + } + + if (install) { + signal?.throwIfAborted() + report.step('Installing dependencies…') + report.output('Running \'npm install\'\n') + await runCommand('npm', ['install'], projectDir, { report, signal }) + } + + return projectDir +} diff --git a/packages/cli/src/dashboard/MetricsClient.ts b/packages/cli/src/dashboard/MetricsClient.ts new file mode 100644 index 0000000..6762e39 --- /dev/null +++ b/packages/cli/src/dashboard/MetricsClient.ts @@ -0,0 +1,275 @@ +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' +import { normalizeLogs, normalizeRequests, normalizeSnapshot } from './snapshot' + +export type ConnectionStatus = 'connecting' | 'live' | 'unreachable' | 'unauthorized' | 'forbidden' | 'disabled' + +export interface DashboardState { + status: ConnectionStatus + /** Where metrics come from, shown in the header */ + source: string + snapshot: ServerMetricsSnapshot | null + /** Date.now() of the last successful poll */ + lastUpdate: number | null + error: string | null + /** Consecutive failed polls */ + failures: number + /** Recent server log lines, oldest first */ + logs: LogLine[] + /** false once the server turned out not to serve logs, null until known */ + logsAvailable: boolean | null + /** Recent HTTP requests to the server, oldest first */ + requests: RequestLine[] + /** false once the server turned out not to record requests, null until known */ + requestsAvailable: boolean | null +} + +/** Log lines kept in memory by the dashboard */ +export const MAX_LOG_LINES = 1000 + +/** HTTP requests kept in memory by the dashboard */ +export const MAX_REQUEST_LINES = 1000 + +export class MetricsSourceError extends Error { + constructor(message: string, readonly status: ConnectionStatus) { + super(message) + } +} + +/** + * Something that produces one raw metrics snapshot per call, + * and optionally the log entries / HTTP requests newer than a given id. + */ +export interface MetricsSource { + label: string + fetch(): Promise + fetchLogs?(afterId: number): Promise + fetchRequests?(afterId: number): Promise +} + +export interface HttpSourceOptions { + url: string + token?: string + timeoutMs?: number + fetch?: typeof globalThis.fetch +} + +export function httpSource({ url, token, timeoutMs = 3000, fetch = globalThis.fetch }: HttpSourceOptions): MetricsSource { + // Logs live next to metrics: .../api/game/metrics -> .../api/game/logs + const logsUrl = new URL('logs', url) + const requestsUrl = new URL('requests', url) + + async function request(target: string) { + let res: Response + + try { + res = await fetch(target, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + signal: AbortSignal.timeout(timeoutMs), + }) + } + catch (err) { + throw new MetricsSourceError(`Can't reach ${url}: ${(err as Error).message}`, 'unreachable') + } + + if (res.status === 401) + throw new MetricsSourceError('Metrics token missing or wrong (use --token)', 'unauthorized') + if (res.status === 403) + throw new MetricsSourceError('Server only allows metrics from localhost without a token (use --token)', 'forbidden') + if (res.status === 404) + throw new MetricsSourceError('Metrics endpoint not found: it is disabled, or the server runs a @mavonengine/core release without it', 'disabled') + if (!res.ok) + throw new MetricsSourceError(`Server responded ${res.status} ${res.statusText}`, 'unreachable') + + return res.json() + } + + return { + label: url, + fetch: () => request(url), + fetchLogs: (afterId) => { + logsUrl.searchParams.set('after', String(afterId)) + return request(logsUrl.toString()) + }, + fetchRequests: (afterId) => { + requestsUrl.searchParams.set('after', String(afterId)) + return request(requestsUrl.toString()) + }, + } +} + +/** + * Follows an endpoint whose entries have increasing ids (logs, HTTP requests), + * keeping the newest `max` of them. + */ +class Feed { + entries: T[] = [] + /** false once the server turned out not to serve it, null until known */ + available: boolean | null + private lastId = 0 + + constructor( + private fetchPage: ((afterId: number) => Promise) | undefined, + private normalize: (raw: unknown) => { entries: T[], latestId: number }, + private max: number, + ) { + this.available = fetchPage ? null : false + } + + async poll() { + if (!this.fetchPage || this.available === false) + return + + try { + let { entries, latestId } = this.normalize(await this.fetchPage(this.lastId)) + + // Ids went backwards, so the server restarted: start over from its first entry + if (latestId < this.lastId) { + this.entries = [] + this.lastId = 0; + ({ entries, latestId } = this.normalize(await this.fetchPage(0))) + } + + this.available = true + this.entries.push(...entries.filter(entry => entry.id > this.lastId)) + this.lastId = Math.max(this.lastId, latestId) + + if (this.entries.length > this.max) + this.entries.splice(0, this.entries.length - this.max) + } + catch (err) { + // Metrics work but this doesn't: an older server, or it is turned off in the server options + if (err instanceof MetricsSourceError && err.status === 'disabled') + this.available = false + // Anything else is transient, the next poll tries again + } + } +} + +export interface MetricsClientOptions { + intervalMs?: number + /** Longest wait between retries while the server is failing */ + maxBackoffMs?: number + /** Poll this often, without backoff, until the first snapshot arrives. For a server that is still starting */ + startupIntervalMs?: number +} + +/** + * Polls a metrics source and keeps the latest dashboard state. + * Knows nothing about rendering so it can be tested without a terminal. + */ +export default class MetricsClient { + readonly state: DashboardState + private intervalMs: number + private maxBackoffMs: number + private startupIntervalMs?: number + private timer: ReturnType | null = null + private running = false + private listeners = new Set<(state: DashboardState) => void>() + private logs: Feed + private requests: Feed + + constructor(private source: MetricsSource, { intervalMs = 1000, maxBackoffMs = 10000, startupIntervalMs }: MetricsClientOptions = {}) { + this.intervalMs = intervalMs + this.maxBackoffMs = maxBackoffMs + this.startupIntervalMs = startupIntervalMs + this.logs = new Feed(source.fetchLogs?.bind(source), normalizeLogs, MAX_LOG_LINES) + this.requests = new Feed(source.fetchRequests?.bind(source), normalizeRequests, MAX_REQUEST_LINES) + this.state = { + status: 'connecting', + source: source.label, + snapshot: null, + lastUpdate: null, + error: null, + failures: 0, + logs: [], + logsAvailable: this.logs.available, + requests: [], + requestsAvailable: this.requests.available, + } + } + + onChange(listener: (state: DashboardState) => void) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + start() { + if (this.running) + return + + this.running = true + void this.loop() + } + + stop() { + this.running = false + + if (this.timer) + clearTimeout(this.timer) + this.timer = null + } + + /** + * Poll once now, skipping the wait. + */ + async refresh() { + if (this.timer) + clearTimeout(this.timer) + this.timer = null + + await this.loop() + } + + /** + * Fetch one snapshot, then any new log lines and requests, and update the state. + */ + async poll() { + try { + const raw = await this.source.fetch() + + this.state.snapshot = normalizeSnapshot(raw) + this.state.status = 'live' + this.state.lastUpdate = Date.now() + this.state.error = null + this.state.failures = 0 + + await Promise.all([this.logs.poll(), this.requests.poll()]) + this.state.logs = this.logs.entries + this.state.logsAvailable = this.logs.available + this.state.requests = this.requests.entries + this.state.requestsAvailable = this.requests.available + } + catch (err) { + this.state.status = err instanceof MetricsSourceError ? err.status : 'unreachable' + this.state.error = (err as Error).message + this.state.failures++ + } + + this.listeners.forEach(listener => listener(this.state)) + } + + /** + * Delay before the next poll: the normal interval, doubling per failure up to maxBackoffMs. + * The startup interval until the server first answered, if one is set. + */ + nextDelay() { + if (this.startupIntervalMs !== undefined && !this.state.snapshot) + return this.startupIntervalMs + + if (this.state.failures === 0) + return this.intervalMs + + return Math.min(this.intervalMs * 2 ** this.state.failures, Math.max(this.maxBackoffMs, this.intervalMs)) + } + + private async loop() { + await this.poll() + + if (this.running && !this.timer) { + this.timer = setTimeout(() => { + this.timer = null + void this.loop() + }, this.nextDelay()) + } + } +} diff --git a/packages/cli/src/dashboard/format.ts b/packages/cli/src/dashboard/format.ts new file mode 100644 index 0000000..a51c8c1 --- /dev/null +++ b/packages/cli/src/dashboard/format.ts @@ -0,0 +1,39 @@ +const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + +export function formatBytes(bytes: number, decimals = 1): string { + if (!Number.isFinite(bytes) || bytes <= 0) + return '0 B' + + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1) + const value = bytes / 1024 ** exponent + + return `${exponent === 0 ? value : value.toFixed(decimals)} ${BYTE_UNITS[exponent]}` +} + +export function formatRate(bytesPerSecond: number): string { + return `${formatBytes(bytesPerSecond)}/s` +} + +export function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)) + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + + if (d > 0) + return `${d}d ${h}h ${m}m` + if (h > 0) + return `${h}h ${m}m ${s}s` + if (m > 0) + return `${m}m ${s}s` + return `${s}s` +} + +export function formatMs(ms: number): string { + return `${ms.toFixed(ms < 10 ? 2 : 1)} ms` +} + +export function formatPercent(value: number): string { + return `${value.toFixed(1)}%` +} diff --git a/packages/cli/src/dashboard/logo.ts b/packages/cli/src/dashboard/logo.ts new file mode 100644 index 0000000..460b074 --- /dev/null +++ b/packages/cli/src/dashboard/logo.ts @@ -0,0 +1,92 @@ +// The MavonEngine logo (https://mavonengine.com/community/assets/site-image-kipwrzwn.png), cropped to the emblem + +/** + * The logo at a few sizes, largest first. One string per pixel row, six hex digits + * per pixel, `------` where the backdrop was cut out. + */ +export const LOGO_SIZES: string[][] = [ + [ + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------174c6b1f627f216682194f6f------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------184a6c2164813d98a958cbd357cdd53e9cae236680194b69------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------1b50723a8aa255b7c344a2b33a9dad399cae3f9caf51b3c13c92a51c5474------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------16436524648047a0b14da7b72e7590185a7a2c859d2e8ba1195d7b276c8848a0b34aa5b5296b851a4868------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------1d56763a8ca250b1be3e8b9e194e6d174c6e29789444b6c646bbc92c7f991a5274194f713884984cacb83d93a5215e7b194668------------------------------------------------------------------------', + '------------------------------------------------------------------1c4f6f357f9655b2bf479eae245c77------205974358da53796ad389bb03a9bb03693a93692a823637f------20566f4090a353b1bf39879c1d5775174366------------------------------------------------------------', + '------------------------------------------------------------2a6a844da6b555acb82f6e87------------296c85358ba225667f14496726768e297b91154865215e7732879e28748b194965------2665804ca3b34eabba29728b------------------------------------------------------------', + '------------------------------------------------1d546d3e8fa152b1be378397194a67------245e7a398fa636879d------------1d54703395aa349aad1e5a73------------2e7e943690a8256782------174869307b904aabba3c93a51d546f------------------------------------------------', + '------------------------------------194b67347b9161c0cc53b0be1e5b78------1b4e6b2e7d94348ca2225c75------------2d76903492a926728c236c86348da32e7a94------------1f53703188a131859d1e5772------16577441a3b556c4ce3583951a4f6a------------------------------------', + '------------------------------25637d49a5b552b1bf3a8a9f4aa2b43d91a52d7d963490a7266a83------------1e516e338ba32e7c941c4b66------------------2a728c338da5215975------------23627d3491a82d839b368fa441a4b533889e4aa8b649abb8286b83------------------------------', + '------------------1e55723c93a54ab1c0307e97174b6c------1d506b3a889b56bfca3c90a4------1a4a672e7c963795ab1f526f------------------------------------1d4d693691a732839a1d516e------33829953bdc93d92a41f5571------17496b2a758f47adba4197a8215c76------------------', + '------------2b728b50bcc84dbbc92780991e64811f5c781d4f6c------------255d774499ab4db3c041a9b92d7e96------------------------------------------------------------2d799343aabb4fb3c148a1b226647c------------1b4c681f5e7b1e6782257d9545b2c155c3ce318198------------', + '------1e516d4dc6d463ecf54fc0cc3fa3b43b9caf3898aa3c9daf34869c1c4c69------------2c6c8348a5b23c91a11f526e------------------------------------------------1d4c6a3b8a9d4eaeb7327a8f------------------347f943c9cae3597a93a9caf3ea2b44bbac862e6f156d4df225d75------', + '------205a774abed14eb6d841a6bc3290a315516e------------307991419fb32c758d------------------3680964ca8b72d6b85------------------------------------29637c4ea9b53b8798------------------2a6c85439fb1368397------------164d6d318ba045aabb4bb1d44dc4da23657f------', + '------1f5b7a43abbe3e7fb4336097357d9c4dabbb358299154665------1e4e6a3385983992a6225c78------------1f53714197aa3d91a6225a771844651845662156743b8ba1449cad245a76------------1e526b388ea0398d9d1f5670------------2f7a914dafbb3b87a2305e913e7eb643aabf236881------', + '------2056753da3b52859874260a51c35681f50744292a84ba7b723607a------------225c763993a8388ca3205171------1b44672b6b892a6a881d53761d52772868882c708c1b4b6c------1948683483993d97a7256379------------1e58754aa6b64ba0b0255977------3f5ea12a59883d9eb023647d------', + '------1d54723b9faf1e4971384f8f364b88------------27627f45a1b237879d1d4e6e------------2e7591409db52f7996194067------19456a215b80225c801a456b------------2d718f419eb3337f97------------------337f9549a7b62e7089------------2f427b3d55941c436a3b9bad24637d------', + '------1c536c3b9eae1b476a2b40783d4d8f------------------1d456738839a4ea8ba397f9a1c476d------1e4e723385a0368ca7255c801f4e772d6d912d6e921f517923587e3989a43b8fa91f5576------16426530758e4da8b73c8ca01f496b------------------3848842e457d------3a9aaa216078------', + '------1d53703b9dad1b4668273b7437468b------2f6c8d396199------------27577b4597ae4ba5b92c6c8c1b40691b3d682c6f93358cad307ba04196b3429ab52e7aa0388eaf2f78991a3f67------28658449a2b34aa2b42a607f------------32558d327495------32407f2b4079------3a99a8206078------', + '------1d51713898ad1c4366263a7139438c------3b8ea95f9ae14e62b42d4380------------2b688843a1b64297b2295b8622457a27548b3376a650b7ce51bbd0347ca925568c20457724557f3e93ad48a8b931728e------------2c40784e61ac6394e24198b5------333f7f2b3f75------3895a7205f78------', + '------1c51703494ab------27397038478e------378fa7417db04b5eb03f54a33d51a2273873------204572367aa14da8c63c76ad335c9f3e83b658c6da59c8dc3e88b82f5e9d396fa649a8c43782a01f476d------23346a42549d4a5ca95164b54776b13e93ac------324281293f76------3693a71f5a78------', + '------1c4f6e3494ab------28397339498f------328ba1286788384f942a41802a3b794452a73d4c9a273a79243d7a3256974078b6448fc147a9cd4bc5d94bc7dc48a7cf4590c4427cb7305995223a7422346e35478e4455a3303e812d3c7e4256a02b5f873b91a9------324381293f77------3692a8215978------', + '------1c4e6e3593ad1d4166283a7437468f------2f879c21607c2541744073b224597e2132693e4f995160b74356a638549d448abf50c4df4ccde146c9dc44c8dc4ac9e054c5e14b91c536569d4154a4505fb53d4e991f2f651f47704077b12d4b86215577368da6------3242802b4078------3591a9205978------', + '------1d4d6e3691ad1d406627387334448b------2f859d205f7c243a6d456eb23a93b22a668e1e356a2f41874567b2468ec84a9dce4da6d451c7e245cbdd44cadc4ecae34eaad54b9bce488ec64968b53143861b2f6523527c3c8fae4677b329407a1e51713288a3------304080293e76------348ca91f5978------', + '------1d4a6f348dac1c3f65253670324388------2e839c205f7e2639703c509b265a843c9bb6327da01f3e702333732b3c853341964160b150a5d644b8d741b9d54badd74066b4314196293883222e711e366f2e70973f9eba2c60893a5196283c771d52713186a1------2f3c7e293a73------338aa51f5479------', + '------1b4a6e348bac------24366f32418a------2c819d1f5d7e24376e394a941b406a2d7c9b40aec6358bad2859892136742a387f3545963f6db73e8dc53c90c53d75b834469a29358220306e2551843586a943a9c2398fab204570384b92273d771e50732e82a1------2e3b7d293872------3189a51f5379------', + '------1c4a6e3287aa------243570313f8a------2a7e9c1f5b7d22346935458d------266d8d2a6d9022567f378bb13784ae26448027357e2e47933066a63169a72c499326337e243e7c3781aa3a90b3255a82245d812f76961d406735478d263a761d4e6f2d7e9f------2d3a7c283874------3085a81c5279------', + '------1c486e3284ab1c3d68263472303f89------287c991e597a213268324089------266d8d2a698d------1b376528638d3285ae2b5f952745832857902958922543832a58903586ad2c6e951c3b6a------24587c2e7595------3243892437711c4c6f2c7a9d------2c3a7e283875------2f82a71d507a------', + '------1b47703081ab1d3c6726347430408b------2777981d567920316731418a------266b8b286588------------------2148752d6f9c2b6093244c80244b7f2a5c912e729f244f7b------------------2256792d7395------3041872436711c486e28779b------2d3b80293879------2f7fa61b5077------', + '------1a446f2d7fac1e3d6e27367b303f8f------26749a1d547b21326a30408c------26658a275f82------------------------------------1d3c681e3d6b1c3465------------------------------2051732c6f92------2e3e862335721a466c257499------2d3d8329387b------2f7da81b4f78------', + '------1a436f2e7eae1f3e6f28377f2e3d8e183b652c86ae22678f1f336a2c3b87------246088275d82------------------------------------------------------------------------------------1f50712a6b8e------2b397f21346f1f5a832a87b01a3f662c39852838811d38692f7eaa1b4c77------', + '------1b426e2e7fae1f4171263c803161ab2c7fa92a779f2a74a02e62a02b3d8b------266088255c84------------------------------------273b72293e77------------------------------------1f4f762a6a90------28387d2d609d2876a027719b2d7fa83364ae2940871d3d722f7dae1b4a77------', + '------1a3f6a2e7daf22578b2f77b23a80c9285090------------2744842e4295------26618f275c85------------------------------------2736722a3878------------------------------------204f772b6b92------2b3b84284585------------274385417ecd337db9255990317cb21b4674------', + '------1b3c693690c6368bc22057881d37732d3b902c3a8f------1c2a662e398e------265d8b265780------------------------------------262f73293178------------------------------------1f4972296790------2c3684202a67------2c3587333d952438782251853487bb3d9ad21b4371------', + '------------2b659b3585c021568b------------202e72333d98313c972d378e------245784275483283072252e6f------------------------262d74292e79------------------------222c6729327521487a275f8b------2c3484333a9438409a28337a------------2152853583bc306fa4------------', + '------------------1d41722d71a73172ac1e4376------------2f368c2f3696------235486244f80------373c92353a90------------------272c7b292d7f------------------313788383e95222b681e4373275b8b------2c3586343893------------1a3c6f2d6ea73075ae214678------------------', + '------------------------------235087347bb52a699f------------2c378f1b346a285c95254f85------------24296937399729307b------272c7b292d7f------252b6f353891282d71------------1e42742b62991b376a2b36881e2c6a------265d96347bb6265a92------------------------------', + '------------------------------------------255a912e74ae2c589c3250a62c629d2f66a5283a87232770------------------33368e37379934349932329635359235358e------------------23286f2838842d64a22e66a13451a42b52992a6faa255c951a3667------------------------------------', + '------------------------------------------------1d3b6f2f69aa3475be27538c------------24277031318e252872------------------3834a03934a3222568------------22276a32338e282b76------------234c803672b6306cab1d3b6f------------------------------------------------', + '------------------------------------------------------------------------------------------------2e2d842a2b7b------------262673282877------------282972302f86------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------292d72413ea3323285------------------302f862b2b7d2d2b892e2b892a2a7a302f87262468------------2d2c7d3e3ca02b2f7b------------------------------------------------------------------------', + '------------------------------------------------------------------------------------34328a3b369b272770------------2d2a7e4037ac4136b0302c86------------2524673a3397363390------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------2325653a3599353290------------2a26782c277c------------322e853e379d2a2870------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------332e883c35a02e2a812f2a84302a852c277939329634308a------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------362f914338b54438b7382f96262469------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------252369242369------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + ], + [ + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + '------------------------------------------------------------------------215c79368ea13790a4225f7b------------------------------------------------------------------------', + '------------------------------------------------------------1a47652f728f439cae3fa0b03d9eb0409aac3077911c4967------------------------------------------------------------', + '------------------------------------------------------235e7c3d8fa23d8da1226582308ca4318fa52267843a899f3d90a326637f------------------------------------------------------', + '------------------------------------------2255713c889d4294a527627b1e536e2d7d963594aa3594a82e7f981e5772255e773e8ea13c8ba0225a77------------------------------------------', + '------------------------------------2b6d834294a63172881e526e2a6c862c738a1b4d6926738c26758d1a4d67276f87286f891d53712d6e873d93a52b6e85------------------------------------', + '------------------------2560794a9eaf53b1bf205d79205b772a738b215771------2a738d28728a276f8729728b------1f536f2a738d21627d1c5e7a44a9ba43a1b027657c------------------------', + '------------1c4b673282973b90a42864812d6e85439dad3689a0194a66225b772d78911d4a66------------------2d778f25617a1a486532839b409fb02b738a24617d388da13584971e4f6a------------', + '------225c764ebdca42aaba2779922668831c4b671f4f6b38849942a0b02e7c93------------------------------------30799144a4b33a8a9e1f526d------2467812779923da4b651c1cd286880------', + '------338aa056cee33ca1b326738a21607c328097296982------23597339869a30728a------------------------306e843b8899276076------28677f34829720627b2570883ca0b255cbe13a97ac------', + '------30819a3e83b131679539879e2d7089------25627b2f7a91225976------2b69843076901d51711d4f702f758e2f6e86------1e526c2f7a8c27667d------2c6e863c8c9f326a933d7fb0328ca1------', + '------2d7b912c6088344989------2e6c8738889d2156731a47672c728d317a96204f731c486c1f577b1f567b1d4a6e1e4d703078922e758e1849651d536d3a8a9e33748c------3247852a5c8530859a------', + '------2c798e24567a344483------------1d446639819a3a829c225175215477307b9828698c2d6c902d6c9129688c347e9c2258791d4e6f37809639839a1f4667------------3345812251732e8396------', + '------2c778f2353752f3e7e20436c498ebc3f549c213b6c2a5f7f3a89a3317394244f7d2d6a99449ebd449fbd2d6e9b214e792e728f3d8ca32c6483213d6a3c50954b8dbf1f4266313f7c214e6e2e8194------', + '------28738e225073304081214a723c8aad465da93648922d3e81234174336e994184b33a75ae4eb1cf4eb3d13976af3f81b032719620426c2b3d7a3c4a924d60af4289b022496e30417d204c6d2d7c94------', + '------28728e235075303f831f486d2b7c9327457a3159902e4383424f9f3a4f9c3c6dac4bb4d549c8dd47c6dd4db4d83e70ae394f993e4d9a2d3e7e30568c2f4b872f799520476b30417e204d6e2c7a95------', + '------2a6f8e234e752d3d7f1e456c2a7891233e703c74a82c6f9329437d39579f417ab74ca5d449c4dd47c7de4ca8d4407ab73b579f273f7b2a658b3c75a52740772b718d1d44682e3f7d1f496d2b7795------', + '------286b8e214d732b3b7c1c426b2877932338702d4e872f809d3388a927508427347c3a59a5408cc53f90c5385da825307a2649803380a43789a630518a253d75296e8e1c41682d3b7b1e466a297394------', + '------27698e214b752b3a7e1c406b26729221366928457a236283265f862c6b9530709e2947892d56982c5798284488306e9d2f709825567e2966872a477d243972276a8c1a3f682c3b7b1e466c286f96------', + '------27658e2149772b39801d3f6c246e8e20326726427a235f801e456c------1f47712a6091265287264f862a6091234b74------------27638328447b22356f2464891b3e692d3b7e204470266d94------', + '------24618e224a7c2c38851d416f2571981f3369253e7a21597c------------------------------1d3765------------------------245d7f25407821336e23688f1b416d2c3981214474256b95------', + '------24608e234c7f2d4e98276c9a28729a2a5692233c7522567c------------------------------------------------------------245a7f213b71295491256e97276a9830529b22487f266796------', + '------2461902d76ab2b66a52a4b93------213774243e7e23557f------------------------------222f66------------------------245a80253c78223874------2c438f3167a92f76ae28689b------', + '------214c7c317bb51c4375------2732812c358b253779214f7b243e71------------------1f2865212866------------------20386d22527e2437752f368a2c35821e2a651d4173327cb2255383------', + '------------1a3765285f96275d92------252c7524367d214d7e203a6c272c6e2c327e------20276a21276a------2b317a282f721c3365234f7f243779272f7a------25588e29629a1d3b69------------', + '------------------------1f44762860982a56982e579e295591212e70------------2e31823233933131912c2f7f------------212c6e2855902e579e2a5396265e9620477b------------------------', + '------------------------------------254b85264a83------------27287524286d------282878282979------222667272a75------------24467c244b83------------------------------------', + '------------------------------------------------272b6e323284------------2728722d2b842d2b83292873------------2e2f7f282d73------------------------------------------------', + '------------------------------------------------------------312e832b2a79------2f2a842f2b87------2a2873332f85232565------------------------------------------------------', + '------------------------------------------------------------------292872312d86302b87302a84302b802b2973------------------------------------------------------------------', + '------------------------------------------------------------------------------322b88332c8a------------------------------------------------------------------------------', + '------------------------------------------------------------------------------------------------------------------------------------------------------------------------', + ], +] diff --git a/packages/cli/src/dashboard/simulator.ts b/packages/cli/src/dashboard/simulator.ts new file mode 100644 index 0000000..6245896 --- /dev/null +++ b/packages/cli/src/dashboard/simulator.ts @@ -0,0 +1,188 @@ +import type { MetricsSource } from './MetricsClient' +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' + +const HISTORY_LENGTH = 60 + +/** + * Small deterministic PRNG so --sim output is the same every run. + */ +function mulberry32(seed: number) { + return () => { + seed |= 0 + seed = (seed + 0x6D2B79F5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function push(history: T[], item: T) { + history.push(item) + if (history.length > HISTORY_LENGTH) + history.shift() +} + +/** + * A fake game server, for working on the dashboard without running one. + * Each fetch advances the simulation by one sample. + */ +export function simulatedSource(seed = 1, sampleIntervalMs = 1000): MetricsSource { + const random = mulberry32(seed) + const tickRate = 30 + const budgetMs = Math.round((1000 / tickRate) * 100) / 100 + + let step = 0 + let now = Date.UTC(2026, 0, 1) + let sequence = 0 + + const cpuHistory: ServerMetricsSnapshot['cpu']['history'] = [] + const bandwidthHistory: ServerMetricsSnapshot['bandwidth']['history'] = [] + const tickHistory: ServerMetricsSnapshot['tick']['history'] = [] + + const logs: LogLine[] = [] + let previousPlayers = 0 + const joinedAt: number[] = [] + const log = (level: string, message: string) => { + logs.push({ id: logs.length + 1, timestamp: now, level, message }) + } + const requests: RequestLine[] = [] + const request = (method: string, path: string, status: number | null, bytes: number, address: string) => { + requests.push({ id: requests.length + 1, timestamp: now, method, path, status, durationMs: Math.round((1 + random() * 20) * 100) / 100, bytes, address }) + } + + log('info', 'Starting @mavon/engine - Socket Server') + log('info', 'Starting @mavon/engine - HTTP Server') + + return { + label: 'simulation', + async fetch() { + step++ + now += sampleIntervalMs + sequence += tickRate + + // Players drift in and out on a slow wave + const playerCount = Math.max(1, Math.round(6 + 5 * Math.sin(step / 20) + random() * 2)) + for (let i = previousPlayers; i < playerCount; i++) + joinedAt[i] = now + + const players = Array.from({ length: playerCount }, (_, i) => ({ + id: `sim-${(i + 1).toString().padStart(2, '0')}`, + displayName: `Player ${i + 1}`, + position: { + x: Math.round(Math.cos(step / 10 + i) * (10 + i * 3) * 100) / 100, + y: 0, + z: Math.round(Math.sin(step / 10 + i) * (10 + i * 3) * 100) / 100, + }, + lastProcessedSequenceId: sequence - Math.floor(random() * 3), + trackedEntities: playerCount + 20 + Math.floor(random() * 5), + connectedFor: Math.round((now - joinedAt[i]) / 1000), + connection: { + address: `203.0.113.${10 + i}`, + rttMs: Math.round(20 + i * 8 + random() * 10), + bytesSent: Math.round((now - joinedAt[i]) * (25 + i)), + bytesReceived: Math.round((now - joinedAt[i]) * (4 + i / 2)), + }, + })) + + for (let i = previousPlayers; i < playerCount; i++) { + // What a browser loading the client and joining asks for + const address = players[i].connection.address + request('GET', '/', 200, 1240, address) + const cached = step % 3 === 0 + request('GET', '/assets/index-4f2a9c1e.js', cached ? 304 : 200, cached ? 210 : 412_000, address) + request('GET', '/favicon.ico', 404, 160, address) + request('POST', '/.wrtc/v2/connections', 200, 2900, address) + request('POST', `/.wrtc/v2/connections/${players[i].id}/additional-candidates`, 200, 600, address) + log('info', `Connected: ${players[i].id}`) + log('info', `${i + 1} total players connected`) + } + for (let i = previousPlayers - 1; i >= playerCount; i--) { + log('info', `Disconnected sim-${(i + 1).toString().padStart(2, '0')}`) + log('info', `${i} total players connected`) + } + previousPlayers = playerCount + + if (step % 10 === 0) + request('GET', '/api/game/health', 200, 180, '198.51.100.7') + if (step % 23 === 0) + request('GET', '/wp-login.php', 404, 160, '192.0.2.66') + + if (step % 5 === 0) + log('debug', `Client - Sent: ${playerCount * 4200} B, Received: ${playerCount * 600} B`) + + const cpu = Math.max(0, 12 + playerCount * 2.5 + Math.sin(step / 7) * 6 + random() * 4) + push(cpuHistory, { timestamp: now, cpuPercentage: Math.round(cpu * 100) / 100 }) + + const sent = Math.round(playerCount * (900 + random() * 300) * tickRate * sampleIntervalMs / 1000) + const received = Math.round(playerCount * (120 + random() * 60) * tickRate * sampleIntervalMs / 1000) + push(bandwidthHistory, { timestamp: now, sent, received }) + + // Occasional spike so the max line has something to show + const avgMs = 2 + playerCount * 0.6 + random() * 1.5 + const maxMs = avgMs + random() * 3 + (step % 17 === 0 ? 25 : 0) + if (maxMs > budgetMs) + log('warn', `Slow tick: ${maxMs.toFixed(1)} ms (budget ${budgetMs} ms)`) + const commandsPerSecond = playerCount * tickRate + push(tickHistory, { + timestamp: now, + ticks: tickRate, + ticksPerSecond: tickRate - (maxMs > budgetMs ? 1 : 0), + avgMs: Math.round(avgMs * 100) / 100, + maxMs: Math.round(maxMs * 100) / 100, + commandsPerSecond, + maxCommandsPerTick: playerCount + Math.floor(random() * 3), + }) + + const average = (ms: number) => { + const recent = cpuHistory.slice(-Math.max(1, Math.round(ms / sampleIntervalMs))) + return Math.round(recent.reduce((acc, h) => acc + h.cpuPercentage, 0) / recent.length * 100) / 100 + } + + const heapUsed = 60e6 + playerCount * 2e6 + (step % 30) * 1e6 + + const snapshot: ServerMetricsSnapshot = { + schemaVersion: 1, + engineVersion: 'simulated', + serverTime: new Date(now).toISOString(), + uptime: Math.round(step * sampleIntervalMs / 1000), + sampleIntervalMs, + tickRate, + players, + world: { entities: playerCount + 140 }, + physics: { bodies: playerCount + 120, colliders: playerCount + 180 }, + cpu: { + current: cpuHistory.at(-1)!.cpuPercentage, + avg1s: average(1000), + avg10s: average(10000), + avg60s: average(60000), + history: [...cpuHistory], + }, + bandwidth: { + sentPerSecond: Math.round(sent * 1000 / sampleIntervalMs), + receivedPerSecond: Math.round(received * 1000 / sampleIntervalMs), + history: [...bandwidthHistory], + }, + tick: { + budgetMs, + latest: tickHistory.at(-1)!, + history: [...tickHistory], + }, + memory: { + rss: heapUsed + 80e6, + heapUsed, + heapTotal: heapUsed + 30e6, + external: 5e6, + }, + latencySimulation: null, + } + + return snapshot + }, + async fetchLogs(afterId) { + return { entries: logs.filter(entry => entry.id > afterId), latestId: logs.length } + }, + async fetchRequests(afterId) { + return { entries: requests.filter(entry => entry.id > afterId), latestId: requests.length } + }, + } +} diff --git a/packages/cli/src/dashboard/snapshot.ts b/packages/cli/src/dashboard/snapshot.ts new file mode 100644 index 0000000..0bbfc57 --- /dev/null +++ b/packages/cli/src/dashboard/snapshot.ts @@ -0,0 +1,175 @@ +import type { LogEntry } from '@mavonengine/core/Networking/Server/Stats/LogBuffer' +import type { RequestLogEntry } from '@mavonengine/core/Networking/Server/Stats/RequestLog' +import type { ServerMetricsSnapshot } from '@mavonengine/core/Networking/Server/Stats/ServerMetrics' + +export type { ServerMetricsSnapshot } +export type LogLine = LogEntry +export type RequestLine = RequestLogEntry + +/** + * The newest metrics schema this CLI understands. + */ +export const SUPPORTED_SCHEMA_VERSION = 1 + +type AnyRecord = Record + +function isRecord(value: unknown): value is AnyRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function num(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function numOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function strOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function arr(value: unknown, map: (item: AnyRecord) => T): T[] { + return Array.isArray(value) ? value.filter(isRecord).map(map) : [] +} + +function section(value: unknown): AnyRecord { + return isRecord(value) ? value : {} +} + +function tickSample(t: AnyRecord) { + return { + timestamp: num(t.timestamp), + ticks: num(t.ticks), + ticksPerSecond: num(t.ticksPerSecond), + avgMs: num(t.avgMs), + maxMs: num(t.maxMs), + commandsPerSecond: num(t.commandsPerSecond), + maxCommandsPerTick: num(t.maxCommandsPerTick), + } +} + +/** + * Turn whatever a server returned into a complete snapshot. + * + * The CLI and the server are released separately, so a field can be missing + * (older server) or unknown (newer server). Missing fields fall back to empty + * values so the dashboard keeps rendering instead of crashing. + */ +export function normalizeSnapshot(raw: unknown): ServerMetricsSnapshot { + const s = section(raw) + const world = section(s.world) + const cpu = section(s.cpu) + const bandwidth = section(s.bandwidth) + const tick = section(s.tick) + const memory = section(s.memory) + const physics = isRecord(s.physics) ? s.physics : null + const latency = isRecord(s.latencySimulation) ? s.latencySimulation : null + + return { + schemaVersion: num(s.schemaVersion, SUPPORTED_SCHEMA_VERSION) as typeof SUPPORTED_SCHEMA_VERSION, + engineVersion: str(s.engineVersion, 'unknown'), + serverTime: str(s.serverTime), + uptime: num(s.uptime), + sampleIntervalMs: num(s.sampleIntervalMs, 1000) || 1000, + tickRate: num(s.tickRate), + players: arr(s.players, (p) => { + const position = section(p.position) + const connection = isRecord(p.connection) ? p.connection : null + const id = str(p.id, '?') + return { + id, + // Older servers don't send a name + displayName: str(p.displayName, id), + position: { x: num(position.x), y: num(position.y), z: num(position.z) }, + lastProcessedSequenceId: num(p.lastProcessedSequenceId), + trackedEntities: num(p.trackedEntities), + connectedFor: num(p.connectedFor), + connection: connection + ? { + address: strOrNull(connection.address), + rttMs: numOrNull(connection.rttMs), + bytesSent: num(connection.bytesSent), + bytesReceived: num(connection.bytesReceived), + } + : null, + } + }), + world: { + entities: num(world.entities), + }, + physics: physics + ? { bodies: num(physics.bodies), colliders: num(physics.colliders) } + : null, + cpu: { + current: num(cpu.current), + avg1s: num(cpu.avg1s), + avg10s: num(cpu.avg10s), + avg60s: num(cpu.avg60s), + history: arr(cpu.history, h => ({ timestamp: num(h.timestamp), cpuPercentage: num(h.cpuPercentage) })), + }, + bandwidth: { + sentPerSecond: num(bandwidth.sentPerSecond), + receivedPerSecond: num(bandwidth.receivedPerSecond), + history: arr(bandwidth.history, b => ({ timestamp: num(b.timestamp), sent: num(b.sent), received: num(b.received) })), + }, + tick: { + budgetMs: num(tick.budgetMs), + latest: isRecord(tick.latest) ? tickSample(tick.latest) : null, + history: arr(tick.history, tickSample), + }, + memory: { + rss: num(memory.rss), + heapUsed: num(memory.heapUsed), + heapTotal: num(memory.heapTotal), + external: num(memory.external), + }, + latencySimulation: latency + ? { fixedLatency: num(latency.fixedLatency), jitter: num(latency.jitter), packetLoss: num(latency.packetLoss) } + : null, + } +} + +/** + * Same as normalizeSnapshot, for a GET /api/game/logs response. + */ +export function normalizeLogs(raw: unknown): { entries: LogLine[], latestId: number } { + const r = section(raw) + const entries = arr(r.entries, e => ({ + id: num(e.id), + timestamp: num(e.timestamp), + level: str(e.level, 'info'), + message: str(e.message), + })) + + return { + entries, + latestId: num(r.latestId, entries.at(-1)?.id ?? 0), + } +} + +/** + * Same as normalizeSnapshot, for a GET /api/game/requests response. + */ +export function normalizeRequests(raw: unknown): { entries: RequestLine[], latestId: number } { + const r = section(raw) + const entries = arr(r.entries, e => ({ + id: num(e.id), + timestamp: num(e.timestamp), + method: str(e.method, '?'), + path: str(e.path), + status: numOrNull(e.status), + durationMs: num(e.durationMs), + bytes: num(e.bytes), + address: strOrNull(e.address), + })) + + return { + entries, + latestId: num(r.latestId, entries.at(-1)?.id ?? 0), + } +} diff --git a/packages/cli/src/dashboard/theme.ts b/packages/cli/src/dashboard/theme.ts new file mode 100644 index 0000000..c74c23b --- /dev/null +++ b/packages/cli/src/dashboard/theme.ts @@ -0,0 +1,23 @@ +import type { Theme } from '@profullstack/hqtui' +import { contrast, defineTheme, mix, resolveTheme } from '@profullstack/hqtui' + +/** + * Contrast the dashboard wants for secondary text (labels, headers, axes). + * Above WCAG AA (4.5) because with a transparent background the real + * background is usually lighter than the theme's own. + */ +export const MIN_MUTED_CONTRAST = 6 + +/** + * The named theme, with `muted` moved towards the foreground until it is readable. + * Most built-in themes ship a muted color around 3-4:1, which is too dim for labels. + */ +export function readableTheme(name?: string): Theme { + const base = resolveTheme(name) + + let muted = base.muted + for (let t = 0.05; contrast(muted, base.background) < MIN_MUTED_CONTRAST && t <= 1; t += 0.05) + muted = mix(base.muted, base.foreground, t) + + return defineTheme({ name: base.name, muted }, base) +} diff --git a/packages/cli/src/dashboard/view.ts b/packages/cli/src/dashboard/view.ts new file mode 100644 index 0000000..ed41439 --- /dev/null +++ b/packages/cli/src/dashboard/view.ts @@ -0,0 +1,632 @@ +import type { Color, Container, Span, Theme } from '@profullstack/hqtui' +import type { ConnectionStatus, DashboardState } from './MetricsClient' +import type { LogLine, RequestLine, ServerMetricsSnapshot } from './snapshot' +import { stripVTControlCharacters } from 'node:util' +import { bootBanner } from '@mavonengine/core/Utils/BootBanner' +import { DEFAULT_COLOR, hex } from '@profullstack/hqtui' +import { formatBytes, formatDuration, formatMs, formatPercent, formatRate } from './format' +import { LOGO_SIZES } from './logo' +import { SUPPORTED_SCHEMA_VERSION } from './snapshot' + +/** + * UI state that lives across frames but isn't part of the metrics. + */ +export interface ViewState { + /** Index into DASHBOARD_TABS */ + tab: number + playerOffset: number + /** Player shown in the details tile on the Players tab */ + selectedPlayerId: string | null + /** Keep the selected row on screen; off while the mouse wheel scrolls the table */ + followSelectedPlayer: boolean + /** Log lines scrolled back from the newest, 0 follows the tail */ + logFromEnd: number + /** Request at the top of the HTTP table while scrolled, null follows the newest */ + requestTopId: number | null +} + +export interface RenderOptions { + /** + * Leave cells on the terminal's default background so a transparent terminal shows through. + * Text is only as readable as whatever is behind the window. + */ + transparent?: boolean + /** Show the `o` key that opens the game in the browser */ + openKey?: boolean + /** Set while `mavonengine dev` or `mavonengine create` waits for the game server it started */ + startup?: StartupInfo +} + +export interface StartupInfo { + /** The project's @mavonengine/core version, if it is installed */ + engineVersion?: string + /** What the dev script printed so far */ + output: string + /** Under the banner, default 'Starting the game server…' */ + label?: string + /** Of the output panel, default 'Dev script' */ + outputTitle?: string +} + +type PlayerRow = ServerMetricsSnapshot['players'][number] + +export const DASHBOARD_TABS = ['Overview', 'Players', 'Logs', 'HTTP'] as const + +const WIDE_LAYOUT = 100 +const NEWLINE_RE = /\r?\n/ + +const STATUS_LABELS: Record = { + connecting: 'CONNECTING', + live: 'LIVE', + unreachable: 'UNREACHABLE', + unauthorized: 'UNAUTHORIZED', + forbidden: 'FORBIDDEN', + disabled: 'METRICS DISABLED', +} + +/** + * The tab a number key switches to, or null if the key isn't one. + */ +export function tabForKey(key: string): number | null { + const index = Number(key) - 1 + return Number.isInteger(index) && index >= 0 && index < DASHBOARD_TABS.length ? index : null +} + +function sortedPlayers(players: ServerMetricsSnapshot['players']) { + return [...players].sort((a, b) => a.id.localeCompare(b.id)) +} + +/** + * Arrow keys / j k move the player selection on the Players tab, escape clears it. + * Returns whether the key was used. + */ +export function handlePlayersKey(view: ViewState, snapshot: ServerMetricsSnapshot | null, key: string): boolean { + if (DASHBOARD_TABS[view.tab] !== 'Players' || !snapshot) + return false + + if (key === 'escape') { + const had = view.selectedPlayerId !== null + view.selectedPlayerId = null + return had + } + + const delta = key === 'down' || key === 'j' ? 1 : key === 'up' || key === 'k' ? -1 : 0 + const rows = sortedPlayers(snapshot.players) + if (delta === 0 || rows.length === 0) + return false + + const current = rows.findIndex(p => p.id === view.selectedPlayerId) + const next = current === -1 + ? (delta > 0 ? 0 : rows.length - 1) + : Math.max(0, Math.min(rows.length - 1, current + delta)) + + view.selectedPlayerId = rows[next].id + view.followSelectedPlayer = true + return true +} + +function statusColor(status: ConnectionStatus, theme: Theme): Color { + if (status === 'live') + return theme.success + if (status === 'connecting') + return theme.warning + return theme.danger +} + +/** + * Draw the whole dashboard. Pure apart from `view`, so it can be rendered in tests. + */ +export function renderDashboard(ui: Container, theme: Theme, state: DashboardState, view: ViewState, { transparent = false, startup, openKey = false }: RenderOptions = {}) { + const { snapshot } = state + const starting = !snapshot && !!startup && (state.status === 'connecting' || state.status === 'unreachable') + + ui.column({}, (root) => { + header(root, theme, state) + + if (snapshot?.latencySimulation) + latencyBanner(root, theme, snapshot.latencySimulation) + + if (state.error && snapshot) + root.text([{ text: ` ${state.error} — showing last known values`, fg: theme.danger }], { size: 1 }) + + if (starting) { + startupScreen(root, theme, startup!) + } + else if (!snapshot) { + waiting(root, theme, state) + } + else { + root.tabs({ + tabs: DASHBOARD_TABS.map((name, i) => `${i + 1} ${name}`), + active: view.tab, + size: 1, + onSelect: (index) => { + view.tab = index + root.ctx.invalidate() + }, + }) + + const tab = DASHBOARD_TABS[view.tab] ?? 'Overview' + if (tab === 'Players') { + playersTab(root, theme, snapshot, view, transparent) + } + else if (tab === 'Logs') { + logsPanel(root, theme, state, view) + } + else if (tab === 'HTTP') { + requestsPanel(root, theme, state, view, transparent) + } + else { + root.responsive({ + [WIDE_LAYOUT]: c => wideLayout(c, theme, state, snapshot, view, transparent), + 0: c => narrowLayout(c, theme, state, snapshot, view, transparent), + }) + } + } + + root.statusBar({ + size: 1, + background: transparent ? DEFAULT_COLOR : undefined, + items: [ + { key: 'q', label: 'Quit' }, + { key: 'r', label: 'Refresh' }, + { key: `1-${DASHBOARD_TABS.length}`, label: 'Tabs' }, + ...(openKey && snapshot ? [{ key: 'o', label: 'Open game' }] : []), + ...(snapshot && DASHBOARD_TABS[view.tab] === 'Players' + ? [{ key: '↑↓', label: 'Select player' }, ...(view.selectedPlayerId ? [{ key: 'esc', label: 'Close' }] : [])] + : []), + ], + right: [starting + ? { label: 'STARTING', color: statusColor('connecting', theme) } + : { label: STATUS_LABELS[state.status], color: statusColor(state.status, theme), active: state.status === 'live' }], + }) + }) +} + +function header(ui: Container, theme: Theme, state: DashboardState) { + const spans: Span[] = [ + { text: ' MavonEngine ', fg: theme.primary, bold: true }, + { text: state.source, fg: theme.muted }, + ] + + if (state.snapshot) { + spans.push( + { text: ' uptime ', fg: theme.muted }, + { text: formatDuration(state.snapshot.uptime) }, + { text: ' engine ', fg: theme.muted }, + { text: state.snapshot.engineVersion }, + ) + + if (state.snapshot.schemaVersion > SUPPORTED_SCHEMA_VERSION) + spans.push({ text: ' newer metrics schema, update @mavonengine/cli', fg: theme.warning }) + } + + ui.text(spans, { size: 1 }) +} + +function latencyBanner(ui: Container, theme: Theme, latency: NonNullable) { + ui.text([{ + text: ` LATENCY SIMULATION ON ${latency.fixedLatency}ms ± ${latency.jitter / 2}ms, ${formatPercent(latency.packetLoss * 100)} packet loss `, + fg: theme.background, + bg: theme.warning, + bold: true, + }], { size: 1 }) +} + +/** Dev script output lines shown under the banner while the server starts */ +const STARTUP_OUTPUT_LINES = 8 + +/** + * Pixel rows drawn two to a line: the top pixel is the glyph, the bottom one its background. + * Cut-out pixels keep the terminal's background, so the logo works on any theme. + */ +function logoLines(pixels: string[]): Span[][] { + const pixel = (row: string | undefined, x: number) => { + const value = row?.slice(x * 6, x * 6 + 6) + return value && value !== '------' ? hex(Number.parseInt(value, 16)) : null + } + + const lines: Span[][] = [] + for (let y = 0; y < pixels.length; y += 2) { + const line: Span[] = [] + for (let x = 0; x < pixels[y].length / 6; x++) { + const top = pixel(pixels[y], x) + const bottom = pixel(pixels[y + 1], x) + if (top !== null) + line.push({ text: '▀', fg: top, bg: bottom ?? undefined }) + else if (bottom !== null) + line.push({ text: '▄', fg: bottom }) + else + line.push({ text: ' ' }) + } + lines.push(line) + } + + return lines +} + +const LOGOS = LOGO_SIZES.map(logoLines) + +function startupScreen(ui: Container, theme: Theme, { engineVersion, output, label = 'Starting the game server…', outputTitle = 'Dev script' }: StartupInfo) { + const lines = stripVTControlCharacters(output).split(NEWLINE_RE).filter(line => line.trim() !== '') + // The game server logs the same banner once it runs + const banner = bootBanner({ version: engineVersion ?? 'unknown', mode: 'development' }) + + ui.column({ size: '1fr' }, (column) => { + const { unicode, colors } = column.ctx.capabilities + // Banner, the spacers around it and the spinner have to fit too. Too few colours make a smudge. + const logo = unicode && (colors === 'truecolor' || colors === 'ansi256') + ? LOGOS.find(l => l.length + banner.length + 3 <= column.height && l[0].length <= column.width) + : undefined + + column.spacer('1fr') + if (logo) { + for (const line of logo) + column.text(line, { align: 'center', size: 1 }) + column.spacer(1) + } + for (const line of banner) + column.text([{ text: line, fg: theme.primary }], { align: 'center', size: 1 }) + column.spacer(1) + column.row({ size: 1 }, (row) => { + row.spacer('1fr') + // Glyph, space, label + row.spinner({ label, size: label.length + 2 }) + row.spacer('1fr') + }) + column.spacer('1fr') + }) + + ui.panel({ title: outputTitle, size: STARTUP_OUTPUT_LINES + 2 }, (panel) => { + if (lines.length === 0) { + panel.label('No output yet') + return + } + + panel.log({ entries: lines.slice(-STARTUP_OUTPUT_LINES).map(message => ({ message })), size: '1fr' }) + }) +} + +function waiting(ui: Container, theme: Theme, state: DashboardState) { + ui.panel({ title: 'Game server', size: '1fr' }, (panel) => { + if (state.status === 'connecting') { + panel.spinner({ label: `Connecting to ${state.source}` }) + return + } + + panel.text([{ text: STATUS_LABELS[state.status], fg: statusColor(state.status, theme), bold: true }]) + panel.text(state.error ?? '', { wrap: true }) + panel.label(`Retrying (attempt ${state.failures})…`) + }) +} + +function wideLayout(ui: Container, theme: Theme, state: DashboardState, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { + ui.grid({ columns: ['1fr', '1fr'], rows: [11, 11], size: 22 }, (grid) => { + grid.panel({ title: 'CPU' }, p => cpuPanel(p, theme, snapshot)) + grid.panel({ title: 'Network' }, p => networkPanel(p, theme, snapshot)) + grid.panel({ title: 'Tick' }, p => tickPanel(p, theme, snapshot)) + grid.panel({ title: 'World' }, p => worldPanel(p, theme, snapshot)) + }) + + ui.row({ size: '1fr' }, (row) => { + playersPanel(row, theme, snapshot, view, transparent) + logsPanel(row, theme, state, view) + }) +} + +function narrowLayout(ui: Container, theme: Theme, state: DashboardState, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { + ui.panel({ title: 'CPU', size: 9 }, p => cpuPanel(p, theme, snapshot)) + ui.panel({ title: 'Network', size: 9 }, p => networkPanel(p, theme, snapshot)) + ui.panel({ title: 'Tick', size: 9 }, p => tickPanel(p, theme, snapshot)) + ui.panel({ title: 'World', size: 11 }, p => worldPanel(p, theme, snapshot)) + playersPanel(ui, theme, snapshot, view, transparent) + logsPanel(ui, theme, state, view) +} + +function cpuPanel(ui: Container, theme: Theme, { cpu }: ServerMetricsSnapshot) { + const values = cpu.history.map(h => h.cpuPercentage) + + ui.graph({ + values, + min: 0, + max: Math.max(100, ...values), + fill: true, + colors: theme.heat, + axis: true, + axisFormat: v => `${Math.round(v)}%`, + size: '1fr', + }) + + ui.meters([ + { label: '1s', value: cpu.avg1s, max: 100, text: formatPercent(cpu.avg1s) }, + { label: '10s', value: cpu.avg10s, max: 100, text: formatPercent(cpu.avg10s) }, + { label: '1m', value: cpu.avg60s, max: 100, text: formatPercent(cpu.avg60s) }, + ], { columns: 3 }) +} + +function networkPanel(ui: Container, theme: Theme, { bandwidth, sampleIntervalMs }: ServerMetricsSnapshot) { + const perSecond = 1000 / sampleIntervalMs + + ui.multiGraph([ + { label: 'sent', values: bandwidth.history.map(b => b.sent * perSecond), color: theme.graph[0] }, + { label: 'received', values: bandwidth.history.map(b => b.received * perSecond), color: theme.graph[1] }, + ], { + min: 0, + axis: true, + axisFormat: formatRate, + legend: true, + legendAlign: 'right', + size: '1fr', + }) + + ui.keyValues([ + { label: 'Sent', value: formatRate(bandwidth.sentPerSecond), color: theme.graph[0] }, + { label: 'Received', value: formatRate(bandwidth.receivedPerSecond), color: theme.graph[1] }, + ], { spread: true }) +} + +function tickPanel(ui: Container, theme: Theme, { tick, tickRate }: ServerMetricsSnapshot) { + const avg = tick.history.map(t => t.avgMs) + const max = tick.history.map(t => t.maxMs) + const latest = tick.latest + + ui.multiGraph([ + { label: 'avg', values: avg, color: theme.graph[0] }, + { label: 'max', values: max, color: theme.graph[2] ?? theme.warning }, + ], { + min: 0, + max: Math.max(tick.budgetMs * 1.25, ...max, 1), + axis: true, + axisFormat: v => `${v.toFixed(0)}ms`, + legend: true, + legendAlign: 'right', + size: '1fr', + }) + + ui.meter({ + label: 'Budget', + value: latest?.avgMs ?? 0, + max: tick.budgetMs || 1, + text: `${formatMs(latest?.avgMs ?? 0)} / ${formatMs(tick.budgetMs)}`, + }) + + ui.keyValues([ + { label: 'Ticks/s', value: `${latest?.ticksPerSecond ?? 0} / ${tickRate}`, color: (latest?.ticksPerSecond ?? 0) < tickRate * 0.9 ? theme.danger : undefined }, + { label: 'Commands/s', value: `${latest?.commandsPerSecond ?? 0} (max ${latest?.maxCommandsPerTick ?? 0}/tick)` }, + ], { spread: true }) +} + +function worldPanel(ui: Container, theme: Theme, { players, world, physics, memory }: ServerMetricsSnapshot) { + ui.keyValues([ + { label: 'Players', value: String(players.length), color: theme.primary }, + { label: 'Entities', value: String(world.entities) }, + { label: 'Physics bodies', value: physics ? String(physics.bodies) : '—' }, + { label: 'Colliders', value: physics ? String(physics.colliders) : '—' }, + { label: 'RSS', value: formatBytes(memory.rss) }, + { label: 'External', value: formatBytes(memory.external) }, + ], { spread: true }) + + ui.spacer('1fr') + + ui.meter({ + label: 'Heap', + value: memory.heapUsed, + max: memory.heapTotal || 1, + text: `${formatBytes(memory.heapUsed)} / ${formatBytes(memory.heapTotal)}`, + }) +} + +function playersTab(ui: Container, theme: Theme, snapshot: ServerMetricsSnapshot, view: ViewState, transparent: boolean) { + const selected = snapshot.players.find(p => p.id === view.selectedPlayerId) + if (!selected) { + // Nothing picked yet, or the player disconnected + view.selectedPlayerId = null + playersPanel(ui, theme, snapshot, view, transparent, true) + return + } + + ui.responsive({ + [WIDE_LAYOUT]: c => c.row({ size: '1fr' }, (row) => { + playersPanel(row, theme, snapshot, view, transparent, true) + playerDetails(row, theme, selected, 42) + }), + 0: (c) => { + playersPanel(c, theme, snapshot, view, transparent, true) + playerDetails(c, theme, selected, 13) + }, + }) +} + +function playerDetails(ui: Container, theme: Theme, player: PlayerRow, size: number) { + const { connection, position } = player + + ui.panel({ title: player.displayName, size }, (panel) => { + panel.keyValues([ + { label: 'ID', value: player.id }, + { label: 'Connected', value: formatDuration(player.connectedFor) }, + { label: 'IP', value: connection ? connection.address ?? 'pending' : '—', color: theme.primary }, + { label: 'RTT', value: connection?.rttMs != null ? formatMs(connection.rttMs) : '—' }, + { label: 'Sent', value: connection ? formatBytes(connection.bytesSent) : '—' }, + { label: 'Received', value: connection ? formatBytes(connection.bytesReceived) : '—' }, + { label: 'Position', value: [position.x, position.y, position.z].map(n => n.toFixed(2)).join(', ') }, + { label: 'Last seq', value: String(player.lastProcessedSequenceId) }, + { label: 'Tracked', value: String(player.trackedEntities) }, + ], { spread: true }) + + if (!connection) + panel.label('No live connection (a bot, or an older server)') + }) +} + +function playersPanel(ui: Container, theme: Theme, { players }: ServerMetricsSnapshot, view: ViewState, transparent: boolean, selectable = false) { + const rows = sortedPlayers(players) + const maxOffset = Math.max(0, rows.length - 1) + view.playerOffset = Math.min(view.playerOffset, maxOffset) + // Display names default to the id, so the column only adds noise until a game sets them + const showNames = rows.some(p => p.displayName !== p.id) + + ui.panel({ title: `Players (${rows.length})`, size: '1fr' }, (panel) => { + if (rows.length === 0) { + panel.label('No players connected') + return + } + + panel.table({ + rows, + offset: view.playerOffset, + selected: selectable ? rows.findIndex(p => p.id === view.selectedPlayerId) : -1, + followSelection: selectable && view.followSelectedPlayer, + // Following the selection can move the window, so keep our offset in step with what is drawn + onRow: (_row, index, y) => { + if (y === 1) + view.playerOffset = index + }, + onSelectRow: selectable + ? (visibleRow) => { + const row = rows[view.playerOffset + visibleRow] + if (!row) + return + view.selectedPlayerId = row.id === view.selectedPlayerId ? null : row.id + ui.ctx.invalidate() + } + : undefined, + scrollbar: true, + // Zebra stripes are solid fills, which would break transparency + zebra: !transparent, + size: '1fr', + onScroll: (delta) => { + view.playerOffset = Math.max(0, Math.min(maxOffset, view.playerOffset + delta)) + view.followSelectedPlayer = false + ui.ctx.invalidate() + }, + columns: [ + ...(showNames ? [{ key: 'displayName', title: 'Name' }] : []), + { key: 'id', title: 'ID' }, + { key: 'x', title: 'X', width: 9, align: 'right', render: p => p.position.x.toFixed(2) }, + { key: 'y', title: 'Y', width: 9, align: 'right', render: p => p.position.y.toFixed(2) }, + { key: 'z', title: 'Z', width: 9, align: 'right', render: p => p.position.z.toFixed(2) }, + { key: 'lastProcessedSequenceId', title: 'Seq', width: 10, align: 'right' }, + { key: 'trackedEntities', title: 'Tracked', width: 8, align: 'right' }, + ], + }) + }) +} + +function formatTime(timestamp: number) { + const date = new Date(timestamp) + return [date.getHours(), date.getMinutes(), date.getSeconds()].map(n => n.toString().padStart(2, '0')).join(':') +} + +/** + * One widget row per line. Multi-line messages (like the boot banner) continue + * on rows without a time or level, and blank lines around them are dropped. + */ +export function toLogRows(lines: LogLine[]) { + return lines.flatMap(({ timestamp, level, message }) => { + const parts = message.split(NEWLINE_RE) + while (parts.length > 1 && parts[0].trim() === '') + parts.shift() + while (parts.length > 1 && parts.at(-1)!.trim() === '') + parts.pop() + + return parts.map((part, i) => i === 0 + ? { time: formatTime(timestamp), level, message: part } + : { message: `${' '.repeat(15)}${part}` }) + }) +} + +function logsPanel(ui: Container, theme: Theme, state: DashboardState, view: ViewState) { + const rows = toLogRows(state.logs) + view.logFromEnd = Math.min(view.logFromEnd, Math.max(0, rows.length - 1)) + + const title = view.logFromEnd > 0 ? `Logs (scrolled back ${view.logFromEnd}, scroll down to follow)` : 'Logs' + + ui.panel({ title, size: '1fr' }, (panel) => { + if (state.logsAvailable === false) { + panel.label('Not available: the server runs a @mavonengine/core release without GET /api/game/logs, or has metrics.logBufferSize set to 0') + return + } + + if (rows.length === 0) { + panel.label('No log output yet') + return + } + + panel.log({ + entries: rows, + fromEnd: view.logFromEnd, + scrollbar: true, + size: '1fr', + levelColors: { + VERBOSE: theme.muted, + SILLY: theme.muted, + HTTP: theme.info, + }, + onScroll: (delta) => { + view.logFromEnd = Math.max(0, Math.min(rows.length - 1, view.logFromEnd - delta * 3)) + ui.ctx.invalidate() + }, + }) + }) +} + +function statusCodeColor(status: number | null, theme: Theme): Color | undefined { + if (status === null || status >= 500) + return theme.danger + if (status >= 400) + return theme.warning + if (status >= 300) + return theme.info + return theme.success +} + +function requestsPanel(ui: Container, theme: Theme, state: DashboardState, view: ViewState, transparent: boolean) { + // Newest first, so new requests appear at the top without scrolling + const rows = [...state.requests].reverse() + // Anchored to a request rather than a row number, so arriving requests don't move a scrolled table + const anchor = view.requestTopId === null ? 0 : rows.findIndex(r => r.id <= view.requestTopId!) + const offset = Math.max(0, anchor) + const errors = rows.filter(r => r.status === null || r.status >= 400).length + + const title = `HTTP requests (${rows.length}${errors ? `, ${errors} error${errors === 1 ? '' : 's'}` : ''})${offset > 0 ? ', scroll up to follow' : ''}` + + ui.panel({ title, size: '1fr' }, (panel) => { + if (state.requestsAvailable === false) { + panel.label('Not available: the server runs a @mavonengine/core release without GET /api/game/requests, or has metrics.requestLogSize set to 0') + return + } + + if (rows.length === 0) { + panel.label('No requests yet') + return + } + + panel.table({ + rows, + offset, + scrollbar: true, + // Zebra stripes are solid fills, which would break transparency + zebra: !transparent, + size: '1fr', + // The table won't scroll past the last full page, so keep the anchor on the row it actually drew + onRow: (_row, index, y) => { + if (y === 1) + view.requestTopId = index === 0 ? null : rows[index].id + }, + onScroll: (delta) => { + const next = Math.max(0, Math.min(rows.length - 1, offset + delta)) + view.requestTopId = next === 0 ? null : rows[next].id + ui.ctx.invalidate() + }, + columns: [ + { key: 'time', title: 'Time', width: 8, color: theme.muted, render: r => formatTime(r.timestamp) }, + { key: 'method', title: 'Method', width: 7, color: r => r.method === 'WS' ? theme.primary : undefined }, + { key: 'status', title: 'Status', width: 6, align: 'right', color: r => statusCodeColor(r.status, theme), render: r => r.status === null ? '—' : String(r.status) }, + { key: 'durationMs', title: 'Duration', width: 10, align: 'right', render: r => formatMs(r.durationMs) }, + { key: 'bytes', title: 'Size', width: 10, align: 'right', render: r => formatBytes(r.bytes) }, + { key: 'address', title: 'Client', width: 18, color: theme.muted, render: r => r.address ?? '—' }, + { key: 'path', title: 'Path' }, + ], + }) + }) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..ecc3f82 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,83 @@ +import process from 'node:process' +import { version } from '../package.json' +import { CREATE_HELP, parseCreateArgs, runCreateCommand } from './commands/create' +import { DASHBOARD_HELP, parseDashboardArgs, runDashboard, UsageError } from './commands/dashboard' +import { DEV_HELP, parseDevArgs, runDevCommand } from './commands/dev' + +const HELP = `Usage: mavonengine [options] + +Commands: + create Create a project from the multiplayer template and start it + dashboard Live terminal dashboard for a running game server + dev Run the project's dev servers (game server and client) + +Options: + -h, --help Show help + -v, --version Show version + +Run 'mavonengine --help' for command options. +` + +async function main(argv: string[]): Promise { + const [command, ...args] = argv + + if (!command || command === '-h' || command === '--help' || command === 'help') { + process.stdout.write(HELP) + return 0 + } + + if (command === '-v' || command === '--version') { + process.stdout.write(`${version}\n`) + return 0 + } + + if (command === 'create') { + const options = parseCreateArgs(args) + + if (options === 'help') { + process.stdout.write(CREATE_HELP) + return 0 + } + + return runCreateCommand(options) + } + + if (command === 'dashboard') { + const options = parseDashboardArgs(args) + + if (options === 'help') { + process.stdout.write(DASHBOARD_HELP) + return 0 + } + + if (!process.stdout.isTTY || !process.stdin.isTTY) { + process.stderr.write('mavonengine dashboard needs an interactive terminal\n') + return 1 + } + + await runDashboard(options) + return 0 + } + + if (command === 'dev') { + const options = parseDevArgs(args) + + if (options === 'help') { + process.stdout.write(DEV_HELP) + return 0 + } + + return runDevCommand(options) + } + + throw new UsageError(`Unknown command '${command}'\n\n${HELP}`) +} + +main(process.argv.slice(2)) + .then(code => process.exit(code)) + .catch((err: Error) => { + // parseArgs throws TypeErrors with an ERR_PARSE_ARGS_* code for bad flags + const isUsage = err instanceof UsageError || (err as NodeJS.ErrnoException).code?.startsWith('ERR_PARSE_ARGS') + process.stderr.write(`mavonengine: ${err.message}\n`) + process.exit(isUsage ? 2 : 1) + }) diff --git a/packages/cli/tests/MetricsClient.test.ts b/packages/cli/tests/MetricsClient.test.ts new file mode 100644 index 0000000..d88c1b2 --- /dev/null +++ b/packages/cli/tests/MetricsClient.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import MetricsClient, { httpSource, MAX_LOG_LINES, MetricsSourceError } from '../src/dashboard/MetricsClient' + +function response(status: number, body: unknown = {}) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +describe('httpSource', () => { + it('sends the token as a bearer header', async () => { + const fetch = vi.fn(async () => response(200, { uptime: 1 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', token: 'secret', fetch }) + + expect(await source.fetch()).toEqual({ uptime: 1 }) + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/metrics', expect.objectContaining({ + headers: { Authorization: 'Bearer secret' }, + })) + }) + + it.each([ + [401, 'unauthorized'], + [403, 'forbidden'], + [404, 'disabled'], + [500, 'unreachable'], + ])('maps HTTP %i to %s', async (status, expected) => { + const source = httpSource({ url: 'http://x.test', fetch: async () => response(status) }) + + await expect(source.fetch()).rejects.toMatchObject({ status: expected }) + }) + + it('reports network errors as unreachable', async () => { + const source = httpSource({ + url: 'http://x.test', + fetch: async () => { + throw new Error('ECONNREFUSED') + }, + }) + + await expect(source.fetch()).rejects.toMatchObject({ status: 'unreachable', message: expect.stringContaining('ECONNREFUSED') }) + }) +}) + +describe('metricsClient', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('goes live after a successful poll', async () => { + const client = new MetricsClient({ label: 'test', fetch: async () => ({ uptime: 42 }) }) + const listener = vi.fn() + client.onChange(listener) + + expect(client.state.status).toBe('connecting') + + await client.poll() + + expect(client.state.status).toBe('live') + expect(client.state.snapshot?.uptime).toBe(42) + expect(client.state.lastUpdate).not.toBeNull() + expect(listener).toHaveBeenCalledWith(client.state) + }) + + it('keeps the last snapshot when a poll fails', async () => { + let fail = false + const client = new MetricsClient({ + label: 'test', + fetch: async () => { + if (fail) + throw new MetricsSourceError('gone', 'unreachable') + return { uptime: 1 } + }, + }) + + await client.poll() + fail = true + await client.poll() + + expect(client.state.status).toBe('unreachable') + expect(client.state.error).toBe('gone') + expect(client.state.failures).toBe(1) + expect(client.state.snapshot?.uptime).toBe(1) + + fail = false + await client.poll() + + expect(client.state.status).toBe('live') + expect(client.state.error).toBeNull() + expect(client.state.failures).toBe(0) + }) + + it('backs off exponentially while failing, up to the max', async () => { + const client = new MetricsClient( + { label: 'test', fetch: async () => { throw new Error('nope') } }, + { intervalMs: 1000, maxBackoffMs: 5000 }, + ) + + expect(client.nextDelay()).toBe(1000) + await client.poll() + expect(client.nextDelay()).toBe(2000) + await client.poll() + expect(client.nextDelay()).toBe(4000) + await client.poll() + expect(client.nextDelay()).toBe(5000) + }) + + it('polls quickly without backoff until a starting server first answers', async () => { + let up = false + const client = new MetricsClient( + { label: 'test', fetch: async () => { + if (!up) + throw new Error('nope') + return {} + } }, + { intervalMs: 1000, startupIntervalMs: 250 }, + ) + + await client.poll() + await client.poll() + expect(client.nextDelay()).toBe(250) + + up = true + await client.poll() + expect(client.nextDelay()).toBe(1000) + + up = false + await client.poll() + expect(client.nextDelay()).toBe(2000) + }) + + it('polls on an interval until stopped', async () => { + vi.useFakeTimers() + const fetch = vi.fn(async () => ({})) + const client = new MetricsClient({ label: 'test', fetch }, { intervalMs: 1000 }) + + client.start() + await vi.advanceTimersByTimeAsync(0) + expect(fetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetch).toHaveBeenCalledTimes(2) + + client.stop() + await vi.advanceTimersByTimeAsync(5000) + expect(fetch).toHaveBeenCalledTimes(2) + }) + + it('refresh polls immediately without doubling the schedule', async () => { + vi.useFakeTimers() + const fetch = vi.fn(async () => ({})) + const client = new MetricsClient({ label: 'test', fetch }, { intervalMs: 1000 }) + + client.start() + await vi.advanceTimersByTimeAsync(0) + await client.refresh() + expect(fetch).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetch).toHaveBeenCalledTimes(3) + + client.stop() + }) + + describe('logs', () => { + function logSource(pages: Array<{ entries: any[], latestId: number } | Error>) { + const calls: number[] = [] + return { + calls, + source: { + label: 'test', + fetch: async () => ({}), + fetchLogs: async (afterId: number) => { + calls.push(afterId) + const page = pages.shift()! + if (page instanceof Error) + throw page + return page + }, + }, + } + } + + const entry = (id: number) => ({ id, timestamp: 0, level: 'info', message: `line ${id}` }) + + it('appends only new lines and asks for what comes after', async () => { + const { source, calls } = logSource([ + { entries: [entry(1), entry(2)], latestId: 2 }, + { entries: [entry(3)], latestId: 3 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 2]) + expect(client.state.logs.map(l => l.id)).toEqual([1, 2, 3]) + expect(client.state.logsAvailable).toBe(true) + }) + + it('starts over when the server restarts', async () => { + const { source, calls } = logSource([ + { entries: [entry(1), entry(2), entry(3)], latestId: 3 }, + { entries: [], latestId: 1 }, + { entries: [entry(1)], latestId: 1 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 3, 0]) + expect(client.state.logs.map(l => l.id)).toEqual([1]) + }) + + it('stops asking once the server says logs are not available', async () => { + const { source, calls } = logSource([new MetricsSourceError('not found', 'disabled')]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0]) + expect(client.state.logsAvailable).toBe(false) + expect(client.state.status).toBe('live') + }) + + it('keeps trying after a transient log error', async () => { + const { source, calls } = logSource([ + new MetricsSourceError('blip', 'unreachable'), + { entries: [entry(1)], latestId: 1 }, + ]) + const client = new MetricsClient(source) + + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 0]) + expect(client.state.logs).toHaveLength(1) + }) + + it('caps the number of lines kept', async () => { + const entries = Array.from({ length: MAX_LOG_LINES + 10 }, (_, i) => entry(i + 1)) + const { source } = logSource([{ entries, latestId: entries.length }]) + const client = new MetricsClient(source) + + await client.poll() + + expect(client.state.logs).toHaveLength(MAX_LOG_LINES) + expect(client.state.logs[0].id).toBe(11) + }) + + it('follows requests the same way as logs', async () => { + const request = (id: number) => ({ id, timestamp: 0, method: 'GET', path: `/${id}`, status: 200, durationMs: 1, bytes: 1, address: null }) + const pages = [{ entries: [request(1), request(2)], latestId: 2 }, { entries: [request(3)], latestId: 3 }] + const calls: number[] = [] + const client = new MetricsClient({ + label: 'test', + fetch: async () => ({}), + fetchRequests: async (afterId: number) => { + calls.push(afterId) + return pages.shift()! + }, + }) + + expect(client.state.logsAvailable).toBe(false) + await client.poll() + await client.poll() + + expect(calls).toEqual([0, 2]) + expect(client.state.requests.map(r => r.path)).toEqual(['/1', '/2', '/3']) + expect(client.state.requestsAvailable).toBe(true) + }) + + it('marks requests unavailable on an older server without failing the poll', async () => { + const client = new MetricsClient({ + label: 'test', + fetch: async () => ({}), + fetchRequests: async () => { + throw new MetricsSourceError('not found', 'disabled') + }, + }) + + await client.poll() + + expect(client.state.requestsAvailable).toBe(false) + expect(client.state.status).toBe('live') + }) + + it('derives the requests url from the metrics url', async () => { + const fetch = vi.fn(async () => response(200, { entries: [], latestId: 0 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', fetch }) + + await source.fetchRequests!(7) + + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/requests?after=7', expect.anything()) + }) + + it('derives the logs url from the metrics url', async () => { + const fetch = vi.fn(async () => response(200, { entries: [], latestId: 0 })) + const source = httpSource({ url: 'http://localhost:8050/api/game/metrics', fetch }) + + await source.fetchLogs!(5) + + expect(fetch).toHaveBeenCalledWith('http://localhost:8050/api/game/logs?after=5', expect.anything()) + }) + }) +}) diff --git a/packages/cli/tests/browser.test.ts b/packages/cli/tests/browser.test.ts new file mode 100644 index 0000000..069c164 --- /dev/null +++ b/packages/cli/tests/browser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { canOpenBrowserHere, openCommand, servesPage } from '../src/browser' + +describe('openCommand', () => { + it('uses the opener of each platform', () => { + expect(openCommand('http://localhost:8050/', 'darwin')).toEqual(['open', ['http://localhost:8050/']]) + expect(openCommand('http://localhost:8050/', 'linux')).toEqual(['xdg-open', ['http://localhost:8050/']]) + expect(openCommand('http://localhost:8050/?a=1&b=2', 'win32')).toEqual(['cmd', ['/c', 'start', '""', 'http://localhost:8050/?a=1^&b=2']]) + }) +}) + +describe('canOpenBrowserHere', () => { + it('is false over SSH', () => { + expect(canOpenBrowserHere({})).toBe(true) + expect(canOpenBrowserHere({ SSH_CONNECTION: '10.0.0.1 51234 10.0.0.2 22' })).toBe(false) + expect(canOpenBrowserHere({ SSH_TTY: '/dev/ttys001' })).toBe(false) + }) +}) + +describe('servesPage', () => { + const answer = (body: string, init: ResponseInit) => (async () => new Response(body, init)) as typeof fetch + + it('is true for an HTML page', async () => { + expect(await servesPage('http://localhost:8050/', { fetch: answer('', { headers: { 'content-type': 'text/html; charset=utf-8' } }) })).toBe(true) + }) + + it('is false for errors, other content and unreachable servers', async () => { + const refused = (async () => { + throw new Error('ECONNREFUSED') + }) as typeof fetch + + expect(await servesPage('http://localhost:8050/', { fetch: answer('Cannot GET /', { status: 404, headers: { 'content-type': 'text/html' } }) })).toBe(false) + expect(await servesPage('http://localhost:8050/', { fetch: answer('{}', { headers: { 'content-type': 'application/json' } }) })).toBe(false) + expect(await servesPage('http://localhost:8050/', { fetch: refused })).toBe(false) + }) +}) diff --git a/packages/cli/tests/create.test.ts b/packages/cli/tests/create.test.ts new file mode 100644 index 0000000..2fbe15f --- /dev/null +++ b/packages/cli/tests/create.test.ts @@ -0,0 +1,123 @@ +import { Buffer } from 'node:buffer' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import AdmZip from 'adm-zip' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { gatedSource, parseCreateArgs } from '../src/commands/create' +import { createProject, extractTemplate, TEMPLATE_SUBPATH } from '../src/create/project' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-create-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function templateZip(files: Record) { + const zip = new AdmZip() + for (const [path, content] of Object.entries(files)) + zip.addFile(path, Buffer.from(content)) + return zip.toBuffer() +} + +const template = { + [`Core-0.0.14-alpha/${TEMPLATE_SUBPATH}/package.json`]: JSON.stringify({ name: '@mavonengine/multiplayer-template', scripts: { dev: 'x' } }), + [`Core-0.0.14-alpha/${TEMPLATE_SUBPATH}/server/index.ts`]: 'export {}', + 'Core-0.0.14-alpha/packages/core/package.json': '{}', +} + +function recorder() { + const steps: string[] = [] + let output = '' + return { steps, output: () => output, report: { step: (s: string) => steps.push(s), output: (c: string) => output += c } } +} + +describe('parseCreateArgs', () => { + it('takes the project name and the dev options', () => { + const options = parseCreateArgs(['my-game', '--port', '9000', '--cwd', 'games'], '/home') as any + + expect(options.projectName).toBe('my-game') + expect(options.cwd).toBe(resolve('/home/games')) + expect(options.dashboard.source.label).toContain(':9000') + }) + + it('leaves the name to the prompt when missing', () => { + expect((parseCreateArgs(['--no-dashboard'], '/home') as any).projectName).toBeUndefined() + }) + + it('rejects bad names and extra positionals', () => { + expect(() => parseCreateArgs(['-bad'])).toThrow() + expect(() => parseCreateArgs(['a', 'b'])).toThrow('one project name') + }) + + it('shows help', () => { + expect(parseCreateArgs(['-h'])).toBe('help') + }) +}) + +describe('extractTemplate', () => { + it('copies only the template folder', () => { + const zipPath = join(dir, 't.zip') + new AdmZip(templateZip(template)).writeZip(zipPath) + + expect(extractTemplate(zipPath, TEMPLATE_SUBPATH, join(dir, 'out'))).toBe(2) + expect(existsSync(join(dir, 'out', 'server', 'index.ts'))).toBe(true) + expect(existsSync(join(dir, 'out', 'packages'))).toBe(false) + }) + + it('fails when the template is missing', () => { + const zipPath = join(dir, 't.zip') + new AdmZip(templateZip({ 'Core/README.md': '' })).writeZip(zipPath) + + expect(() => extractTemplate(zipPath, TEMPLATE_SUBPATH, join(dir, 'out'))).toThrow('not found') + }) +}) + +describe('createProject', () => { + const fetchZip = (async () => new Response(templateZip(template))) as typeof fetch + + it('downloads, extracts and names the project, reporting each step', async () => { + const { steps, output, report } = recorder() + + const projectDir = await createProject({ projectName: 'my-game', parentDir: dir, report, fetch: fetchZip, install: false }) + + expect(projectDir).toBe(join(dir, 'my-game')) + expect(JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).name).toBe('my-game') + expect(steps[0]).toBe('Downloading the template…') + expect(steps.some(s => s.includes(' B'))).toBe(true) + expect(steps.at(-1)).toBe('Extracting the template…') + expect(output()).toContain('Extracted 2 files') + }) + + it('refuses an existing directory', async () => { + mkdirSync(join(dir, 'taken')) + + await expect(createProject({ projectName: 'taken', parentDir: dir, report: recorder().report, fetch: fetchZip, install: false })) + .rejects + .toThrow('already exists') + }) + + it('reports a failed download', async () => { + const notFound = (async () => new Response('nope', { status: 404, statusText: 'Not Found' })) as typeof fetch + + await expect(createProject({ projectName: 'x', parentDir: dir, report: recorder().report, fetch: notFound, install: false })) + .rejects + .toThrow('404 Not Found') + }) +}) + +describe('gatedSource', () => { + it('keeps reporting connecting until ready', async () => { + let ready = false + const source = gatedSource({ label: 'http://localhost:8050', fetch: async () => ({ ok: true }) }, () => ready) + + await expect(source.fetch()).rejects.toMatchObject({ status: 'connecting' }) + ready = true + await expect(source.fetch()).resolves.toEqual({ ok: true }) + expect(source.label).toBe('http://localhost:8050') + }) +}) diff --git a/packages/cli/tests/dashboardArgs.test.ts b/packages/cli/tests/dashboardArgs.test.ts new file mode 100644 index 0000000..c683a34 --- /dev/null +++ b/packages/cli/tests/dashboardArgs.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { parseDashboardArgs, UsageError } from '../src/commands/dashboard' + +describe('parseDashboardArgs', () => { + it('defaults to the local server', () => { + const options = parseDashboardArgs([], {}) + + expect(options).not.toBe('help') + if (options === 'help') + return + + expect(options.source.label).toBe('http://localhost:8050/api/game/metrics') + expect(options.intervalMs).toBe(1000) + }) + + it('builds the url from host and port', () => { + const options = parseDashboardArgs(['--host', 'game.example.com', '--port', '9000'], {}) + + expect(options !== 'help' && options.source.label).toBe('http://game.example.com:9000/api/game/metrics') + }) + + it('wraps IPv6 hosts in brackets', () => { + const options = parseDashboardArgs(['--host', '::1'], {}) + + expect(options !== 'help' && options.source.label).toBe('http://[::1]:8050/api/game/metrics') + }) + + it('opens the client from the root of the metrics server', () => { + const local = parseDashboardArgs(['--port', '9000'], {}) + const proxied = parseDashboardArgs(['--url', 'https://game.example.com/api/game/metrics'], {}) + const sim = parseDashboardArgs(['--sim'], {}) + + expect(local !== 'help' && local.clientUrl).toBe('http://localhost:9000/') + expect(proxied !== 'help' && proxied.clientUrl).toBe('https://game.example.com/') + expect(sim !== 'help' && sim.clientUrl).toBeUndefined() + }) + + it('uses --url as is', () => { + const options = parseDashboardArgs(['--url', 'https://game.example.com/api/game/metrics'], {}) + + expect(options !== 'help' && options.source.label).toBe('https://game.example.com/api/game/metrics') + }) + + it('is opaque unless --transparent is passed', () => { + const opaque = parseDashboardArgs([], {}) + const transparent = parseDashboardArgs(['--transparent'], {}) + + expect(opaque !== 'help' && opaque.transparent).toBe(false) + expect(transparent !== 'help' && transparent.transparent).toBe(true) + }) + + it('switches to the simulator with --sim', () => { + const options = parseDashboardArgs(['--sim'], {}) + + expect(options !== 'help' && options.source.label).toBe('simulation') + }) + + it('returns help', () => { + expect(parseDashboardArgs(['--help'], {})).toBe('help') + expect(parseDashboardArgs(['-h'], {})).toBe('help') + }) + + it.each([ + [['--port', '0']], + [['--port', 'abc']], + [['--interval', '10']], + [['--url', 'not a url']], + ])('rejects %j', (args) => { + expect(() => parseDashboardArgs(args, {})).toThrow(UsageError) + }) + + it('rejects unknown flags', () => { + expect(() => parseDashboardArgs(['--nope'], {})).toThrow() + }) +}) diff --git a/packages/cli/tests/dev.test.ts b/packages/cli/tests/dev.test.ts new file mode 100644 index 0000000..a5219f3 --- /dev/null +++ b/packages/cli/tests/dev.test.ts @@ -0,0 +1,188 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { descendants, detectPackageManager, findProjectRoot, parseDevArgs, runDevCommand, startDev } from '../src/commands/dev' + +let dir: string + +function writePackage(path: string, pkg: object) { + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify(pkg)) +} + +const project = { dependencies: { '@mavonengine/core': '0.0.20' }, scripts: { dev: 'concurrently ...' } } + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-dev-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +describe('findProjectRoot', () => { + it('finds the project in the start directory', () => { + writePackage(dir, project) + + expect(findProjectRoot(dir)).toBe(dir) + }) + + it('walks up from a workspace to the project root', () => { + writePackage(dir, project) + writePackage(join(dir, 'client'), { scripts: { dev: 'vite' } }) + mkdirSync(join(dir, 'client', 'src')) + + expect(findProjectRoot(join(dir, 'client', 'src'))).toBe(dir) + }) + + it('accepts @mavonengine/core as a dev dependency', () => { + writePackage(dir, { devDependencies: { '@mavonengine/core': '*' }, scripts: { dev: 'x' } }) + + expect(findProjectRoot(dir)).toBe(dir) + }) + + it('skips packages without a dev script', () => { + writePackage(dir, project) + writePackage(join(dir, 'lib'), { dependencies: { '@mavonengine/core': '*' } }) + + expect(findProjectRoot(join(dir, 'lib'))).toBe(dir) + }) + + it('ignores unreadable package.json files', () => { + writePackage(dir, project) + mkdirSync(join(dir, 'broken')) + writeFileSync(join(dir, 'broken', 'package.json'), '{ nope') + + expect(findProjectRoot(join(dir, 'broken'))).toBe(dir) + }) + + it('returns undefined outside a project', () => { + writePackage(dir, { scripts: { dev: 'vite' } }) + + expect(findProjectRoot(dir)).toBeUndefined() + }) +}) + +describe('detectPackageManager', () => { + it('defaults to npm', () => { + expect(detectPackageManager(dir)).toBe('npm') + }) + + it.each([ + ['package-lock.json', 'npm'], + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ])('picks the manager from %s', (lockfile, expected) => { + writeFileSync(join(dir, lockfile), '') + + expect(detectPackageManager(dir)).toBe(expected) + }) +}) + +describe('descendants', () => { + it('collects the whole tree below a process', () => { + const ps = ` + 1 0 + 10 1 + 11 10 + 12 10 + 13 11 + 20 1 + ` + + expect(descendants(10, ps).sort()).toEqual([11, 12, 13]) + expect(descendants(13, ps)).toEqual([]) + }) +}) + +describe('parseDevArgs', () => { + it('defaults to the current directory', () => { + expect(parseDevArgs([], '/work')).toMatchObject({ cwd: resolve('/work') }) + }) + + it('resolves --cwd against the current directory', () => { + expect(parseDevArgs(['--cwd', 'game'], '/work')).toMatchObject({ cwd: resolve('/work/game') }) + }) + + it('opens the browser unless --no-open is passed or the terminal is an SSH session', () => { + expect(parseDevArgs([], '/work', {})).toMatchObject({ dashboard: { openBrowser: true, clientUrl: 'http://localhost:8050/' } }) + expect(parseDevArgs(['--no-open'], '/work', {})).toMatchObject({ dashboard: { openBrowser: false } }) + expect(parseDevArgs([], '/work', { SSH_TTY: '/dev/ttys001' })).toMatchObject({ dashboard: { openBrowser: false } }) + }) + + it('shows the dashboard for the local server by default', () => { + const options = parseDevArgs([], '/work', {}) + + expect(options).toMatchObject({ dashboard: { intervalMs: 1000, transparent: false } }) + expect((options as any).dashboard.source.label).toBe('http://localhost:8050/api/game/metrics') + }) + + it('passes dashboard options on', () => { + const options = parseDevArgs(['--port', '9000', '--theme', 'nord', '--transparent'], '/work', {}) + + expect(options).toMatchObject({ dashboard: { theme: 'nord', transparent: true } }) + expect((options as any).dashboard.source.label).toBe('http://localhost:9000/api/game/metrics') + }) + + it('turns the dashboard off with --no-dashboard', () => { + expect(parseDevArgs(['--no-dashboard'], '/work')).toEqual({ cwd: resolve('/work'), dashboard: false }) + }) + + it('rejects a bad dashboard port', () => { + expect(() => parseDevArgs(['--port', 'x'])).toThrow(/--port/) + }) + + it('returns help', () => { + expect(parseDevArgs(['-h'])).toBe('help') + }) + + it('rejects unknown flags', () => { + expect(() => parseDevArgs(['--nope'])).toThrow() + }) +}) + +describe('runDevCommand', () => { + it('fails with a usage error outside a project', async () => { + await expect(runDevCommand({ cwd: dir, dashboard: false })).rejects.toThrow(/no MavonEngine project found/) + }) + + it('runs the dev script and returns its exit code', async () => { + writePackage(dir, { ...project, scripts: { dev: 'node -e "process.exit(3)"' } }) + + expect(await runDevCommand({ cwd: dir, dashboard: false })).toBe(3) + }) +}) + +describe.skipIf(process.platform === 'win32')('startDev', () => { + it('captures the output of the dev script', async () => { + writePackage(dir, { ...project, scripts: { dev: 'node -e "console.log(\'hello from dev\')"' } }) + + const dev = startDev(dir, { capture: true }) + + expect(await dev.exited).toBe(0) + expect(dev.output()).toContain('hello from dev') + }) + + it('stops the dev script and everything it started', async () => { + // A grandchild that ignores SIGTERM, like a server that hangs on shutdown + writeFileSync(join(dir, 'server.cjs'), ` + process.on('SIGTERM', () => {}) + require('node:fs').writeFileSync('server.pid', String(process.pid)) + setInterval(() => {}, 1000) + `) + writePackage(dir, { ...project, scripts: { dev: 'node server.cjs' } }) + + const dev = startDev(dir, { capture: true }) + const pidFile = join(dir, 'server.pid') + await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true), { timeout: 5000 }) + const serverPid = Number(readFileSync(pidFile, 'utf8')) + + await dev.stop() + + expect(() => process.kill(serverPid, 0)).toThrow() + }, 15000) +}) diff --git a/packages/cli/tests/format.test.ts b/packages/cli/tests/format.test.ts new file mode 100644 index 0000000..acd83f8 --- /dev/null +++ b/packages/cli/tests/format.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, formatDuration, formatMs, formatRate } from '../src/dashboard/format' + +describe('format', () => { + it('formats bytes', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1536)).toBe('1.5 KiB') + expect(formatBytes(5 * 1024 ** 3)).toBe('5.0 GiB') + expect(formatBytes(Number.NaN)).toBe('0 B') + expect(formatRate(2048)).toBe('2.0 KiB/s') + }) + + it('formats durations', () => { + expect(formatDuration(5)).toBe('5s') + expect(formatDuration(65)).toBe('1m 5s') + expect(formatDuration(3725)).toBe('1h 2m 5s') + expect(formatDuration(90061)).toBe('1d 1h 1m') + }) + + it('formats ms', () => { + expect(formatMs(3.14159)).toBe('3.14 ms') + expect(formatMs(33.333)).toBe('33.3 ms') + }) +}) diff --git a/packages/cli/tests/snapshot.test.ts b/packages/cli/tests/snapshot.test.ts new file mode 100644 index 0000000..40d6853 --- /dev/null +++ b/packages/cli/tests/snapshot.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { simulatedSource } from '../src/dashboard/simulator' +import { normalizeLogs, normalizeSnapshot } from '../src/dashboard/snapshot' + +describe('normalizeSnapshot', () => { + it('keeps a complete snapshot as is', async () => { + const snapshot = await simulatedSource().fetch() + + expect(normalizeSnapshot(snapshot)).toEqual(snapshot) + }) + + it('fills in everything for an empty response', () => { + const snapshot = normalizeSnapshot({}) + + expect(snapshot.players).toEqual([]) + expect(snapshot.cpu.history).toEqual([]) + expect(snapshot.tick.latest).toBeNull() + expect(snapshot.physics).toBeNull() + expect(snapshot.latencySimulation).toBeNull() + expect(snapshot.sampleIntervalMs).toBe(1000) + expect(snapshot.engineVersion).toBe('unknown') + }) + + it('survives garbage', () => { + expect(() => normalizeSnapshot(null)).not.toThrow() + expect(() => normalizeSnapshot('nope')).not.toThrow() + expect(normalizeSnapshot({ players: [null, 3, { id: 'a' }] }).players).toEqual([ + { id: 'a', displayName: 'a', position: { x: 0, y: 0, z: 0 }, lastProcessedSequenceId: 0, trackedEntities: 0, connectedFor: 0, connection: null }, + ]) + }) + + it('keeps unknown connection details as null', () => { + const [player] = normalizeSnapshot({ players: [{ id: 'a', connection: { address: 7, rttMs: Number.NaN, bytesSent: 10 } }] }).players + expect(player.connection).toEqual({ address: null, rttMs: null, bytesSent: 10, bytesReceived: 0 }) + }) + + it('replaces non-finite numbers', () => { + const snapshot = normalizeSnapshot({ cpu: { current: Number.NaN, avg1s: 'x' }, sampleIntervalMs: 0 }) + + expect(snapshot.cpu.current).toBe(0) + expect(snapshot.cpu.avg1s).toBe(0) + expect(snapshot.sampleIntervalMs).toBe(1000) + }) + + it('ignores fields it does not know', () => { + const snapshot = normalizeSnapshot({ uptime: 5, somethingNew: { a: 1 } }) + + expect(snapshot.uptime).toBe(5) + expect(snapshot).not.toHaveProperty('somethingNew') + }) +}) + +describe('normalizeLogs', () => { + it('fills in missing fields and drops garbage entries', () => { + expect(normalizeLogs({ entries: [{ id: 4, message: 'hi' }, null, 'x'] })).toEqual({ + entries: [{ id: 4, timestamp: 0, level: 'info', message: 'hi' }], + latestId: 4, + }) + }) + + it('handles an empty response', () => { + expect(normalizeLogs(undefined)).toEqual({ entries: [], latestId: 0 }) + }) +}) diff --git a/packages/cli/tests/theme.test.ts b/packages/cli/tests/theme.test.ts new file mode 100644 index 0000000..e8ac986 --- /dev/null +++ b/packages/cli/tests/theme.test.ts @@ -0,0 +1,33 @@ +import { contrast, themes } from '@profullstack/hqtui' +import { describe, expect, it } from 'vitest' +import { MIN_MUTED_CONTRAST, readableTheme } from '../src/dashboard/theme' + +describe('readableTheme', () => { + it.each(Object.keys(themes))('makes muted text readable in %s', (name) => { + const theme = readableTheme(name) + + expect(contrast(theme.muted, theme.background)).toBeGreaterThanOrEqual(MIN_MUTED_CONTRAST) + }) + + it('keeps muted dimmer than the foreground', () => { + const theme = readableTheme('dark') + + expect(contrast(theme.muted, theme.background)).toBeLessThan(contrast(theme.foreground, theme.background)) + }) + + it('leaves already readable themes alone', () => { + expect(readableTheme('highContrast').muted).toBe(themes.highContrast.muted) + }) + + it('keeps everything else from the base theme', () => { + const theme = readableTheme('nord') + + expect(theme.name).toBe('nord') + expect(theme.primary).toBe(themes.nord.primary) + expect(theme.background).toBe(themes.nord.background) + }) + + it('falls back to the default theme', () => { + expect(readableTheme().name).toBe(themes.dark.name) + }) +}) diff --git a/packages/cli/tests/view.test.ts b/packages/cli/tests/view.test.ts new file mode 100644 index 0000000..d477ec2 --- /dev/null +++ b/packages/cli/tests/view.test.ts @@ -0,0 +1,349 @@ +import type { DashboardState } from '../src/dashboard/MetricsClient' +import type { StartupInfo, ViewState } from '../src/dashboard/view' +import { DEFAULT_COLOR, renderToScreen } from '@profullstack/hqtui' +import { describe, expect, it } from 'vitest' +import { simulatedSource } from '../src/dashboard/simulator' +import { normalizeSnapshot } from '../src/dashboard/snapshot' +import { handlePlayersKey, renderDashboard, tabForKey, toLogRows } from '../src/dashboard/view' + +async function liveState(samples = 30): Promise { + const source = simulatedSource() + let raw: unknown + for (let i = 0; i < samples; i++) + raw = await source.fetch() + + return { + status: 'live', + source: 'http://localhost:8050/api/game/metrics', + snapshot: normalizeSnapshot(raw), + lastUpdate: Date.now(), + error: null, + failures: 0, + logs: [ + { id: 1, timestamp: 0, level: 'info', message: 'Starting @mavon/engine - Socket Server' }, + { id: 2, timestamp: 0, level: 'warn', message: 'Slow tick: 40.1 ms (budget 33.33 ms)' }, + ], + logsAvailable: true, + requests: [ + { id: 1, timestamp: 0, method: 'GET', path: '/assets/index.js', status: 200, durationMs: 3.2, bytes: 412000, address: '203.0.113.10' }, + { id: 2, timestamp: 0, method: 'GET', path: '/favicon.ico', status: 404, durationMs: 0.8, bytes: 160, address: '203.0.113.10' }, + { id: 3, timestamp: 0, method: 'WS', path: '/hmr', status: 101, durationMs: 1.1, bytes: 129, address: '127.0.0.1' }, + ], + requestsAvailable: true, + } +} + +function viewState(overrides: Partial = {}): ViewState { + return { tab: 0, playerOffset: 0, logFromEnd: 0, requestTopId: null, selectedPlayerId: null, followSelectedPlayer: true, ...overrides } +} + +function render(state: DashboardState, width = 130, height = 40, transparent = false, tab = 0, view = viewState({ tab }), startup?: StartupInfo) { + return renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, view, { transparent, startup }), { width, height }) +} + +function startingState(status: DashboardState['status']): DashboardState { + return { status, source: 'http://localhost:8050/api/game/metrics', snapshot: null, lastUpdate: null, error: 'Can\'t reach', failures: 3, logs: [], logsAvailable: null, requests: [], requestsAvailable: null } +} + +describe('dashboard view', () => { + it('shows every panel when live', async () => { + const screen = render(await liveState()) + + for (const title of ['CPU', 'Network', 'Tick', 'World', 'Players (', 'LIVE', 'q Quit']) + expect(screen.contains(title), title).toBe(true) + + expect(screen.contains('sim-01')).toBe(true) + }) + + it('shows only the players on the players tab', async () => { + const screen = render(await liveState(), 130, 40, false, 1) + + expect(screen.contains('2 Players')).toBe(true) + expect(screen.contains('Players (')).toBe(true) + expect(screen.contains('sim-01')).toBe(true) + expect(screen.contains('CPU')).toBe(false) + expect(screen.contains('Slow tick')).toBe(false) + }) + + it('shows only the logs on the logs tab', async () => { + const screen = render(await liveState(), 130, 40, false, 2) + + expect(screen.contains('Slow tick')).toBe(true) + expect(screen.contains('Players (')).toBe(false) + expect(screen.contains('CPU')).toBe(false) + }) + + it('shows HTTP requests, newest first, on the http tab', async () => { + const screen = render(await liveState(), 130, 40, false, 3) + + expect(screen.contains('4 HTTP')).toBe(true) + expect(screen.contains('HTTP requests (3, 1 error)')).toBe(true) + for (const text of ['/assets/index.js', '/favicon.ico', '404', 'WS', '/hmr', '402.3 KiB']) + expect(screen.contains(text), text).toBe(true) + expect(screen.find('/hmr')!.y).toBeLessThan(screen.find('/favicon.ico')!.y) + expect(screen.contains('Slow tick')).toBe(false) + }) + + it('keeps a scrolled http table on the same request as new ones arrive', async () => { + const state = await liveState() + const request = (id: number) => ({ id, timestamp: 0, method: 'GET', path: `/page-${id}`, status: 200, durationMs: 1, bytes: 10, address: null }) + state.requests = Array.from({ length: 80 }, (_, i) => request(i + 1)) + const view = viewState({ tab: 3, requestTopId: 60 }) + + state.requests.push(request(81)) + const screen = render(state, 130, 40, false, 3, view) + + expect(screen.find('/page-60 ')!.y).toBe(screen.find('Time')!.y + 1) + expect(screen.contains('/page-81')).toBe(false) + expect(screen.contains('scroll up to follow')).toBe(true) + expect(view.requestTopId).toBe(60) + }) + + it('follows the newest requests again once everything fits', async () => { + const view = viewState({ tab: 3, requestTopId: 2 }) + + const screen = render(await liveState(), 130, 40, false, 3, view) + + expect(screen.contains('/hmr')).toBe(true) + expect(view.requestTopId).toBeNull() + }) + + it('explains when the server does not record requests', async () => { + const state = await liveState() + state.requests = [] + state.requestsAvailable = false + + expect(render(state, 130, 40, false, 3).contains('requestLogSize')).toBe(true) + }) + + it('shows a details tile for the selected player', async () => { + const state = await liveState() + const player = state.snapshot!.players[0] + + const closed = render(state, 130, 40, false, 1) + expect(closed.contains(player.connection!.address!)).toBe(false) + + for (const [width, height] of [[130, 40], [80, 60]]) { + const screen = render(state, width, height, false, 1, viewState({ tab: 1, selectedPlayerId: player.id })) + expect(screen.contains(player.connection!.address!), `${width}x${height}`).toBe(true) + expect(screen.contains('Connected')).toBe(true) + expect(screen.contains('esc Close')).toBe(true) + } + }) + + it('explains a player without a connection', async () => { + const state = await liveState() + const player = state.snapshot!.players[0] + player.connection = null + + const screen = render(state, 130, 40, false, 1, viewState({ tab: 1, selectedPlayerId: player.id })) + expect(screen.contains('No live connection')).toBe(true) + }) + + it('drops the selection when the player disconnects', async () => { + const state = await liveState() + const view = viewState({ tab: 1, selectedPlayerId: 'gone' }) + + render(state, 130, 40, false, 1, view) + expect(view.selectedPlayerId).toBeNull() + }) + + it('moves the player selection with the arrow keys', async () => { + const { snapshot } = await liveState() + const ids = snapshot!.players.map(p => p.id).sort() + const view = viewState({ tab: 1 }) + + expect(handlePlayersKey(view, snapshot, 'down')).toBe(true) + expect(view.selectedPlayerId).toBe(ids[0]) + handlePlayersKey(view, snapshot, 'j') + expect(view.selectedPlayerId).toBe(ids[1]) + handlePlayersKey(view, snapshot, 'up') + handlePlayersKey(view, snapshot, 'up') + expect(view.selectedPlayerId).toBe(ids[0]) + + expect(handlePlayersKey(view, snapshot, 'escape')).toBe(true) + expect(view.selectedPlayerId).toBeNull() + expect(handlePlayersKey(view, snapshot, 'escape')).toBe(false) + }) + + it('ignores the arrow keys outside the players tab', async () => { + const { snapshot } = await liveState() + const view = viewState({ tab: 0 }) + + expect(handlePlayersKey(view, snapshot, 'down')).toBe(false) + expect(view.selectedPlayerId).toBeNull() + }) + + it('maps number keys to tabs', () => { + expect(tabForKey('1')).toBe(0) + expect(tabForKey('4')).toBe(3) + expect(tabForKey('5')).toBeNull() + expect(tabForKey('0')).toBeNull() + expect(tabForKey('q')).toBeNull() + }) + + it('fits a narrow terminal', async () => { + const screen = render(await liveState(), 80, 60) + + expect(screen.contains('CPU')).toBe(true) + expect(screen.contains('Players (')).toBe(true) + }) + + it('shows a waiting panel before the first snapshot', () => { + const screen = render({ + status: 'unauthorized', + source: 'http://x.test', + snapshot: null, + lastUpdate: null, + error: 'Metrics token missing or wrong (use --token)', + failures: 2, + logs: [], + logsAvailable: null, + requests: [], + requestsAvailable: null, + }) + + expect(screen.contains('UNAUTHORIZED')).toBe(true) + expect(screen.contains('use --token')).toBe(true) + expect(screen.contains('attempt 2')).toBe(true) + }) + + it('shows the open key once the server answers, when there is a client to open', async () => { + const state = await liveState() + const withKey = renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, state, viewState(), { openKey: true }), { width: 130, height: 40 }) + const starting = renderToScreen(({ ui, theme }) => renderDashboard(ui, theme, startingState('connecting'), viewState(), { openKey: true }), { width: 130, height: 40 }) + + expect(withKey.contains('Open game')).toBe(true) + expect(render(state).contains('Open game')).toBe(false) + expect(starting.contains('Open game')).toBe(false) + }) + + it('shows the startup screen while the dev server starts', () => { + const screen = render(startingState('unreachable'), 130, 40, false, 0, viewState(), { + engineVersion: '0.0.20', + output: '\u001B[32mbuilding\u001B[39m\n\nESM Build success\n', + }) + + expect(screen.contains('MavonEngine')).toBe(true) + expect(screen.contains('Version: 0.0.20')).toBe(true) + expect(screen.contains('Starting the game server')).toBe(true) + expect(screen.contains('ESM Build success')).toBe(true) + expect(screen.contains('STARTING')).toBe(true) + expect(screen.contains('UNREACHABLE')).toBe(false) + }) + + it('shows what mavonengine create is doing on the startup screen', () => { + const screen = render(startingState('connecting'), 130, 40, false, 0, viewState(), { + output: 'Running \'npm install\'\nadded 312 packages\n', + label: 'Installing dependencies…', + outputTitle: 'Setup', + }) + + expect(screen.contains('Installing dependencies')).toBe(true) + expect(screen.contains('Setup')).toBe(true) + expect(screen.contains('added 312 packages')).toBe(true) + expect(screen.contains('Starting the game server')).toBe(false) + }) + + it('draws the logo above the banner when the terminal is tall enough', () => { + const startup = { engineVersion: '0.0.20', output: '' } + const logo = (height: number, capabilities = {}) => renderToScreen( + ({ ui, theme }) => renderDashboard(ui, theme, startingState('unreachable'), viewState(), { startup }), + { width: 130, height, capabilities }, + ).contains('▀') + + expect(logo(60)).toBe(true) + // Room for the smaller size only + expect(logo(46)).toBe(true) + expect(logo(30)).toBe(false) + expect(logo(60, { unicode: false })).toBe(false) + expect(logo(60, { colors: 'ansi16' })).toBe(false) + }) + + it('leaves the startup screen for a server that answers with an error', () => { + const state = startingState('unauthorized') + state.error = 'Metrics token missing or wrong (use --token)' + const screen = render(state, 130, 40, false, 0, viewState(), { output: '' }) + + expect(screen.contains('Starting the game server')).toBe(false) + expect(screen.contains('use --token')).toBe(true) + }) + + it('keeps the last values and shows the error when the server drops', async () => { + const state = await liveState() + state.status = 'unreachable' + state.error = 'Can\'t reach localhost' + + const screen = render(state) + + expect(screen.contains('showing last known values')).toBe(true) + expect(screen.contains('UNREACHABLE')).toBe(true) + expect(screen.contains('CPU')).toBe(true) + }) + + it('warns when latency simulation is on', async () => { + const state = await liveState() + state.snapshot!.latencySimulation = { fixedLatency: 100, jitter: 20, packetLoss: 0.05 } + + expect(render(state).contains('LATENCY SIMULATION ON')).toBe(true) + }) + + it('shows an empty players message', async () => { + const state = await liveState() + state.snapshot!.players = [] + + expect(render(state).contains('No players connected')).toBe(true) + }) + + it('shows the name column only when a player has a name besides its id', async () => { + const state = await liveState() + expect(render(state).contains('Name')).toBe(true) + + for (const player of state.snapshot!.players) + player.displayName = player.id + expect(render(state).contains('Name')).toBe(false) + }) + + it('fills the status bar unless transparent', async () => { + const state = await liveState() + + const opaque = render(state) + const quit = opaque.find('Quit')! + expect(opaque.cell(quit.x, quit.y).bg).not.toBe(DEFAULT_COLOR) + + const transparent = render(state, 130, 40, true) + const quitTransparent = transparent.find('Quit')! + expect(transparent.cell(quitTransparent.x, quitTransparent.y).bg).toBe(DEFAULT_COLOR) + }) + + it('shows log lines', async () => { + const screen = render(await liveState()) + + expect(screen.contains('Logs')).toBe(true) + expect(screen.contains('Starting @mavon/engine - Socket Server')).toBe(true) + expect(screen.contains('WARN')).toBe(true) + }) + + it('shows logs in the narrow layout', async () => { + expect(render(await liveState(), 80, 70).contains('Slow tick')).toBe(true) + }) + + it('explains when the server does not serve logs', async () => { + const state = await liveState() + state.logs = [] + state.logsAvailable = false + + expect(render(state).contains('Not available')).toBe(true) + }) +}) + +describe('toLogRows', () => { + it('splits multi-line messages and trims blank edges', () => { + const rows = toLogRows([{ id: 1, timestamp: 0, level: 'info', message: '\n\n┌──┐\n│ME│\n└──┘\n' }]) + + expect(rows).toHaveLength(3) + expect(rows[0]).toMatchObject({ level: 'info', message: '┌──┐' }) + expect(rows[1]).not.toHaveProperty('level') + expect(rows[2].message.trim()).toBe('└──┘') + }) +}) diff --git a/packages/cli/tsconfig.check.json b/packages/cli/tsconfig.check.json new file mode 100644 index 0000000..c9c2fa2 --- /dev/null +++ b/packages/cli/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "tests"] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..f494f0f --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..c7fea1f --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: false, + clean: true, + target: 'node24', + banner: { + js: '#!/usr/bin/env node', + }, +}) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..f964be2 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/packages/core/README.md b/packages/core/README.md index acc0717..3de8648 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,7 +8,7 @@ rendering, physics, networking, animation, and debugging into a single cohesive ## Get Started ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` | URL | Description | diff --git a/packages/core/package.json b/packages/core/package.json index 5f79bb3..fc487e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@mavonengine/core", "type": "module", - "version": "0.0.20", + "version": "0.0.21", "description": "", "author": "", "license": "MIT", @@ -34,6 +34,9 @@ "vite", "vite.config.js" ], + "engines": { + "node": ">=24" + }, "scripts": { "prepare": "node scripts/generate-version.js", "build": "node scripts/generate-version.js && rm -f tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json && find src -name '*.glsl' -o -name '*.css' | while read f; do mkdir -p dist/$(dirname ${f#src/}) && cp $f dist/${f#src/}; done", diff --git a/packages/core/src/BaseGame.ts b/packages/core/src/BaseGame.ts index 2576bf2..25ac0b4 100644 --- a/packages/core/src/BaseGame.ts +++ b/packages/core/src/BaseGame.ts @@ -4,6 +4,7 @@ import type Logger from './Utils/Logger' import type GameObjectInterface from './World/GameObjectInterface' import { version as RAPIER_VERSION } from '@dimforge/rapier3d-compat' import { Clock, Raycaster, Scene, REVISION as THREE_REVISION } from 'three' +import { bootBanner } from './Utils/BootBanner' import EventEmitter from './Utils/EventEmitter' import { version as ENGINE_VERSION } from './version' @@ -46,20 +47,13 @@ export default class BaseGame extends EventEmitter implements GameObjectInterfac this.logger?.info([ '', '', - '┌─────────────────────────────────────────┐', - '│ │', - '│ MavonEngine │', - '│ │', - `│ Version: ${ENGINE_VERSION.padEnd(28)} │`, - `│ Three.js: r${THREE_REVISION.padEnd(27)} │`, - ...(physicsWorld ? [`│ Rapier: ${RAPIER_VERSION().padEnd(28)} │`] : []), - // eslint-disable-next-line node/prefer-global/process - `│ Mode: ${(process.env.NODE_ENV ?? 'development').padEnd(28)} │`, - '│ │', - '│ https://github.com/MavonEngine/core │', - '│ https://mavonengine.com │', - '│ │', - '└─────────────────────────────────────────┘', + ...bootBanner({ + version: ENGINE_VERSION, + threeRevision: THREE_REVISION, + rapierVersion: physicsWorld ? RAPIER_VERSION() : undefined, + // eslint-disable-next-line node/prefer-global/process + mode: process.env.NODE_ENV ?? 'development', + }), '', ].join('\n')) @@ -102,6 +96,8 @@ export default class BaseGame extends EventEmitter implements GameObjectInterfac const tickNow = performance.now() this.update((tickNow - this.lastTickTime) / 1000) this.lastTickTime = tickNow + + this.trigger('tickComplete', performance.now() - tickNow) } onUpdate(callback: (delta: number) => void): void { diff --git a/packages/core/src/Networking/Entities/Player.ts b/packages/core/src/Networking/Entities/Player.ts index 091fb10..fa272e7 100644 --- a/packages/core/src/Networking/Entities/Player.ts +++ b/packages/core/src/Networking/Entities/Player.ts @@ -1,5 +1,18 @@ +import type { ServerChannel } from '@geckos.io/server' import NetworkedLivingActor from '../NetworkedLivingActor' +/** + * What the transport knows about a player's connection. + */ +export interface PlayerConnectionInfo { + /** Remote IP of the WebRTC connection, null until ICE picks a candidate pair */ + address: string | null + /** Round trip time in ms, null until measured */ + rttMs: number | null + bytesSent: number + bytesReceived: number +} + /** * Base class for connected clients / Players */ @@ -8,12 +21,62 @@ export default abstract class Player extends NetworkedLivingActor { trackedEntities = new Set() + /** + * Epoch ms the player was created. On the server that is when its client connected. + */ + connectedAt = Date.now() + + /** + * The client's channel. Set by the server on connection, undefined for bots and on the client. + */ + channel?: ServerChannel + private _lastProcessedSequenceId = 0 + private _displayName: string | null = null + get lastProcessedSequenceId() { return this._lastProcessedSequenceId } + /** + * Human readable name, shown in tools like the CLI dashboard. + * Falls back to the id until the game sets one. + */ + get displayName(): string { + return this._displayName ?? this.id + } + + /** + * Pass null to fall back to the id again. + */ + setDisplayName(name: string | null) { + this._displayName = name + } + + /** + * Live details of the client's WebRTC connection, null without one. + */ + get connection(): PlayerConnectionInfo | null { + const peer = this.channel?.webrtcConnection?.peerConnection + if (!peer) + return null + + // node-datachannel throws once the peer connection is closed + try { + const rtt = peer.rtt() + return { + address: peer.getSelectedCandidatePair()?.remote.address ?? null, + rttMs: rtt >= 0 ? rtt : null, + bytesSent: peer.bytesSent(), + bytesReceived: peer.bytesReceived(), + } + } + catch { + return { address: null, rttMs: null, bytesSent: 0, bytesReceived: 0 } + } + } + updateLastProcessedSequenceId(sequenceId: number) { this._lastProcessedSequenceId = sequenceId } diff --git a/packages/core/src/Networking/Server/Http/ClientMiddleware.ts b/packages/core/src/Networking/Server/Http/ClientMiddleware.ts new file mode 100644 index 0000000..0e10a77 --- /dev/null +++ b/packages/core/src/Networking/Server/Http/ClientMiddleware.ts @@ -0,0 +1,73 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express' +import type { Server as NodeServer } from 'node:http' +import type { ViteDevServer } from 'vite' +import type winston from 'winston' +import { existsSync } from 'node:fs' +import { extname, join, resolve } from 'node:path' +import express from 'express' + +export interface ClientOptions { + /** Client project directory, where index.html and vite.config live */ + root: string + + /** + * Serve the client source through Vite with hot reload instead of the built files. + * Off by default so production never loads Vite, whatever NODE_ENV says. + */ + dev?: boolean + + /** Built client served when `dev` is off. Default `/dist` */ + dist?: string +} + +/** + * Serve the client source through a Vite dev server in middleware mode. + * Hot reload shares the HTTP server's port. Requests that arrive while Vite is starting wait for it. + */ +export function devClientMiddleware(root: string, server: NodeServer, logger: winston.Logger): RequestHandler { + const vite: Promise = import('vite').then(({ createServer }) => createServer({ + root: resolve(root), + appType: 'spa', + server: { middlewareMode: true, hmr: { server } }, + })) + + vite.then( + (dev) => { + logger.info(`Serving the client from ${root} through Vite`) + server.on('close', () => void dev.close()) + }, + err => logger.error(`Could not start Vite for the client: ${(err as Error).message}`), + ) + + return (req: Request, res: Response, next: NextFunction) => { + vite.then(dev => dev.middlewares(req, res, next), next) + } +} + +/** + * Serve a built client, falling back to index.html for page routes so client-side routing works. + * Paths with a file extension are files, so a missing one stays a 404 instead of turning into the page. + * Returns undefined when there is nothing built at `dist`. + */ +export function staticClientMiddleware(dist: string): RequestHandler | undefined { + const index = join(dist, 'index.html') + if (!existsSync(index)) + return undefined + + const router = express.Router() + router.use(express.static(dist, { index: false })) + router.use((req, res, next) => { + const isPage = (req.method === 'GET' || req.method === 'HEAD') + && !req.path.startsWith('/api/') + && !extname(req.path) + && req.accepts('html') + if (!isPage) { + next() + return + } + + res.sendFile(resolve(index)) + }) + + return router +} diff --git a/packages/core/src/Networking/Server/Http/HttpServer.ts b/packages/core/src/Networking/Server/Http/HttpServer.ts new file mode 100644 index 0000000..ef5fb25 --- /dev/null +++ b/packages/core/src/Networking/Server/Http/HttpServer.ts @@ -0,0 +1,210 @@ +import type { Express, NextFunction, Request, Response } from 'express' +import type { Server as NodeServer } from 'node:http' +import type winston from 'winston' +import type LogBuffer from '../Stats/LogBuffer' +import type RequestLog from '../Stats/RequestLog' +import type ServerMetrics from '../Stats/ServerMetrics' +import type { ClientOptions } from './ClientMiddleware' +import { Buffer } from 'node:buffer' +import { timingSafeEqual } from 'node:crypto' +import { join } from 'node:path' +import process from 'node:process' +import express from 'express' +import { devClientMiddleware, staticClientMiddleware } from './ClientMiddleware' + +export interface MetricsEndpointOptions { + /** + * Expose GET /api/game/metrics, GET /api/game/logs and GET /api/game/requests. + * Defaults to true, but in production it stays off unless a token is set. + */ + enabled?: boolean + + /** + * Required as `Authorization: Bearer ` or `?token=`. + * Without a token the endpoint only answers requests from loopback addresses. + */ + token?: string +} + +export interface HttpServerOptions { + port?: number + metrics?: MetricsEndpointOptions + /** Serves recent log entries on GET /api/game/logs when given */ + logs?: LogBuffer + /** Records the requests this server receives and serves them on GET /api/game/requests when given */ + requests?: RequestLog + /** Serve the game client from this server, see `serveClient()` */ + client?: ClientOptions +} + +const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) + +/** + * How recently metrics must have been requested for a dashboard to count as watching. + * Comfortably above the dashboard's default 1s poll and its 10s retry backoff. + */ +export const METRICS_CLIENT_TIMEOUT_MS = 15000 + +export default class HttpServer { + readonly app: Express + + private port: number + private metricsOptions: MetricsEndpointOptions + private logs?: LogBuffer + private requests?: RequestLog + private clientOptions?: ClientOptions + private lastMetricsRequestAt?: number + private listener?: NodeServer + + constructor( + private logger: winston.Logger, + private metrics: ServerMetrics, + options: HttpServerOptions = {}, + app: Express = express(), + ) { + this.app = app + this.port = options.port ?? 8050 + this.metricsOptions = options.metrics ?? {} + this.logs = options.logs + this.requests = options.requests + this.clientOptions = options.client + + this.registerHealthEndpoint() + this.registerMetricsEndpoint() + this.registerLogsEndpoint() + this.registerRequestsEndpoint() + } + + start() { + this.logger.info('Starting @mavon/engine - HTTP Server') + this.listener = this.app.listen(this.port) + this.requests?.attach(this.listener) + + this.logger.info(`Visit game server status: http://localhost:${this.port}/api/game/health`) + } + + /** + * The underlying Node HTTP server, once started. + */ + get server(): NodeServer | undefined { + return this.listener + } + + /** + * Mount the game client configured in `options.client`. Call it after every other route is registered, + * because the client answers any page request that is left. + * Returns whether a client is being served. + */ + serveClient(): boolean { + const client = this.clientOptions + if (!client || !this.listener) + return false + + if (client.dev) { + this.app.use(devClientMiddleware(client.root, this.listener, this.logger)) + return true + } + + const dist = client.dist ?? join(client.root, 'dist') + const middleware = staticClientMiddleware(dist) + if (!middleware) { + this.logger.warn(`No built client at ${dist}, run the client build to serve it from the game server`) + return false + } + + this.app.use(middleware) + this.logger.info(`Serving the built client from ${dist}: http://localhost:${this.port}/`) + return true + } + + get metricsEnabled() { + const { enabled = true, token } = this.metricsOptions + + if (!enabled) + return false + + return process.env.NODE_ENV !== 'production' || !!token + } + + private registerHealthEndpoint() { + this.app.get('/api/game/health', (_req, res) => { + res.json(this.metrics.health()) + }) + } + + private registerMetricsEndpoint() { + this.app.get('/api/game/metrics', (req, res, next) => this.authorizeMetrics(req, res, next), (_req, res) => { + this.lastMetricsRequestAt = Date.now() + res.json(this.metrics.snapshot()) + }) + } + + /** + * Whether something (usually `mavonengine dashboard`) has fetched metrics recently. + */ + metricsClientActive(now = Date.now()) { + return this.lastMetricsRequestAt !== undefined && now - this.lastMetricsRequestAt < METRICS_CLIENT_TIMEOUT_MS + } + + /** + * GET /api/game/logs?after= returns the entries newer than `after`. + */ + private registerLogsEndpoint() { + this.app.get('/api/game/logs', (req, res, next) => this.authorizeMetrics(req, res, next), (req, res) => { + if (!this.logs) { + res.status(404).end() + return + } + + const after = Number(req.query.after ?? 0) + res.json(this.logs.since(Number.isFinite(after) ? after : 0)) + }) + } + + /** + * GET /api/game/requests?after= returns the HTTP requests recorded after `after`. + */ + private registerRequestsEndpoint() { + this.app.get('/api/game/requests', (req, res, next) => this.authorizeMetrics(req, res, next), (req, res) => { + if (!this.requests) { + res.status(404).end() + return + } + + const after = Number(req.query.after ?? 0) + res.json(this.requests.since(Number.isFinite(after) ? after : 0)) + }) + } + + private authorizeMetrics(req: Request, res: Response, next: NextFunction) { + if (!this.metricsEnabled) { + res.status(404).end() + return + } + + const { token } = this.metricsOptions + + if (token) { + const header = req.headers.authorization + const provided = header?.startsWith('Bearer ') ? header.slice(7) : req.query.token + + if (typeof provided !== 'string' || !safeEqual(provided, token)) { + res.status(401).json({ error: 'Invalid metrics token' }) + return + } + } + else if (!LOOPBACK_ADDRESSES.has(req.socket.remoteAddress ?? '')) { + res.status(403).json({ error: 'Metrics are only available from localhost unless a token is configured' }) + return + } + + next() + } +} + +function safeEqual(a: string, b: string) { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB) +} diff --git a/packages/core/src/Networking/Server/LatencySimulator.ts b/packages/core/src/Networking/Server/LatencySimulator.ts index f30dab7..eb8bcab 100644 --- a/packages/core/src/Networking/Server/LatencySimulator.ts +++ b/packages/core/src/Networking/Server/LatencySimulator.ts @@ -29,6 +29,10 @@ export default class LatencySimulator { logger.warn(`RUNNING WITH LATENCY SIMULATION: latency ${this.options.fixedLatency}ms, jitter: ${this.options.jitter}ms, packet loss: ${this.options.packetLoss}%`) } + get config(): Readonly> { + return this.options + } + /** * Calculate our latency items and then * run the callback as appropriate diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index cec17ba..2af2903 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -3,42 +3,78 @@ import type { Data, GeckosServer, ServerChannel } from '@geckos.io/server' import type { Express } from 'express' import type winston from 'winston' import type GameObject from '../../World/GameObject' -import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' +import type { ClientOptions } from './Http/ClientMiddleware' +import type { MetricsEndpointOptions } from './Http/HttpServer' import type { LatencySimulatorOptions } from './LatencySimulator' +import type { ServerMetricsOptions, ServerMetricsSource } from './Stats/ServerMetrics' import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' -import express from 'express' import BaseGame from '../../BaseGame' +import Player from '../Entities/Player' import { ServerCommand } from './Commands' +import HttpServer from './Http/HttpServer' import LatencySimulator from './LatencySimulator' -import BandwidthTracker from './Stats/BandwidthTracker' -import CpuTracker from './Stats/CpuTracker' +import LogBuffer from './Stats/LogBuffer' +import RequestLog from './Stats/RequestLog' +import ServerMetrics, { SERVER_BANDWIDTH_ID } from './Stats/ServerMetrics' import ServerWorld from './World' let instance: Server export interface ServerOptions { latencySimulation?: LatencySimulatorOptions + + /** + * Separate port for the geckos.io game socket, or false for none. + * Defaults to none when the HTTP server serves the client, since the socket is reachable there, and 8081 otherwise. + */ + socketPort?: number | false + + /** Port the HTTP server listens on. Default 8050 */ + httpPort?: number + + /** + * Serve the game client from the HTTP server: the source through Vite when `dev` is set, the built files otherwise. + * The game socket is then also reachable on the HTTP port, so the client needs no second origin. + */ + client?: ClientOptions + + metrics?: MetricsEndpointOptions & ServerMetricsOptions & { + /** How often a CPU / bandwidth summary is logged, in ms. Default 5000 */ + logIntervalMs?: number + + /** How many recent log entries are kept for GET /api/game/logs. 0 turns it off. Default 500 */ + logBufferSize?: number + + /** How many recent HTTP requests are kept for GET /api/game/requests. 0 turns it off. Default 500 */ + requestLogSize?: number + } } -export default abstract class Server { +export default abstract class Server implements ServerMetricsSource { game: BaseGame private commandBuffer: IncomingClientCommandPacket[] = [] gameSocket: GeckosServer - httpServer: Express = express() protected logger: winston.Logger + readonly metrics: ServerMetrics + readonly http: HttpServer + readonly logs?: LogBuffer + readonly requests?: RequestLog + private onHttpCallback?: (httpServer: Express) => void + private options: ServerOptions lastTime = performance.now() - bandwidthTracker = new BandwidthTracker() - private cpuTracker: CpuTracker private latencySimulator?: LatencySimulator + private commandsProcessedThisTick = 0 + private dashboardWatching = false + private timers: ReturnType[] = [] protected abstract onConnection(channel: ServerChannel): TClient protected abstract onCommand(command: any, delta: number): void @@ -46,21 +82,43 @@ export default abstract class Server { constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World, options?: ServerOptions) { instance = this as unknown as Server + // Attached first so the boot banner is captured too + const logBufferSize = options?.metrics?.logBufferSize ?? 500 + if (logBufferSize > 0) { + this.logs = new LogBuffer(logBufferSize) + logger.add(this.logs.transport) + } + this.game = new BaseGame(logger, phyicsWorld) this.game.world = new ServerWorld() this.gameSocket = geckos() this.logger = logger - this.cpuTracker = new CpuTracker(this.logger) + this.options = options ?? {} - if (options?.latencySimulation) { - this.latencySimulator = new LatencySimulator(options.latencySimulation, this.logger) + if (this.options.latencySimulation) { + this.latencySimulator = new LatencySimulator(this.options.latencySimulation, this.logger) } + const requestLogSize = this.options.metrics?.requestLogSize ?? 500 + if (requestLogSize > 0) + this.requests = new RequestLog(requestLogSize) + + this.metrics = new ServerMetrics(this, this.logger, this.options.metrics) + this.http = new HttpServer(this.logger, this.metrics, { + port: this.options.httpPort, + metrics: this.options.metrics, + logs: this.logs, + requests: this.requests, + client: this.options.client, + }) + this.gameSocket.onConnection((channel) => { this.logger.info(`Connected: ${channel.id}`) const client = this.onConnection(channel) + if (client instanceof Player) + client.channel = channel this.game.world.entities.items.set(client.id!.toString(), client) this.logger.info(`${(this.game.world as ServerWorld).players.length} total players connected`) @@ -108,22 +166,29 @@ export default abstract class Server { channel.on('command', (data) => { const bytes = Buffer.byteLength(JSON.stringify(data)) - this.bandwidthTracker.recordReceived('server', bytes) + this.bandwidthTracker.recordReceived(SERVER_BANDWIDTH_ID, bytes) this.bufferIncomingCommand(channel, data) }) }) this.game.onUpdate(delta => this.tick(delta)) + this.game.on('tickComplete', (durationMs: number) => { + this.metrics.tick.record(durationMs, this.commandsProcessedThisTick) + this.commandsProcessedThisTick = 0 + }) + } - setInterval(() => { - const { sent, received } = this.bandwidthTracker.getBandwidthUsage('server') - this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) + get httpServer(): Express { + return this.http.app + } - this.cpuTracker.log() + get bandwidthTracker() { + return this.metrics.bandwidth + } - this.bandwidthTracker.reset() - }, 5000) + get latencySimulation() { + return this.latencySimulator?.config } private bufferIncomingCommand(channel: ServerChannel, command: Data) { @@ -134,7 +199,7 @@ export default abstract class Server { } private tick(delta: number) { - this.runThroughBuffer() + this.commandsProcessedThisTick += this.runThroughBuffer() this.onTick(delta) this.stateSync() @@ -193,7 +258,7 @@ export default abstract class Server { type: ServerCommand.SV_STATE, } - this.bandwidthTracker.recordSent('server', Buffer.byteLength(JSON.stringify(state))) + this.bandwidthTracker.recordSent(SERVER_BANDWIDTH_ID, Buffer.byteLength(JSON.stringify(state))) const SendState = () => con.channel.emit(ServerCommand.SV_STATE, state) @@ -215,7 +280,10 @@ export default abstract class Server { */ abstract getStateSyncDistance(): number - private runThroughBuffer() { + /** + * Process every buffered command and return how many were processed. + */ + private runThroughBuffer(): number { /** * Due to network latency packets could come in different orders. First step is sort by sequence Id * @@ -229,10 +297,27 @@ export default abstract class Server { */ this.commandBuffer.sort((a, b) => a.sequenceId! - b.sequenceId!) - while (this.commandBuffer.length > 0) { - const currentCommand = this.commandBuffer[0] + const commands = this.commandBuffer + this.commandBuffer = [] + + const delta = this.calculateDelta() + + for (const currentCommand of commands) { + const process = () => { + // The player may have disconnected while the latency simulator held the command back + const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player | undefined + if (!player) + return + + this.onCommand(currentCommand, delta) - const process = () => this.onCommand(currentCommand, this.calculateDelta()) + /** + * Update the players lastProcessedSequenceId after the command has executed. + * Simulated jitter can complete commands out of order so never move it backwards. + */ + if (currentCommand.sequenceId! > player.lastProcessedSequenceId) + player.updateLastProcessedSequenceId(currentCommand.sequenceId!) + } if (this.latencySimulator) { this.latencySimulator.handle(process) @@ -240,22 +325,18 @@ export default abstract class Server { else { process() } + } - /** - * Update the players lastProcessedSequenceId after the command has executed - */ - const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player - player.updateLastProcessedSequenceId(currentCommand.sequenceId!) + this.lastTime = performance.now() - this.commandBuffer.shift() - } + return commands.length } + /** + * Ms since the command buffer was last processed + */ private calculateDelta() { - const currentTime = performance.now() - const deltaTime = (currentTime - this.lastTime) - - return deltaTime + return performance.now() - this.lastTime } protected onTick(_delta: number): void { } @@ -265,10 +346,8 @@ export default abstract class Server { } start() { - this.logger.info('Starting @mavon/engine - Socket Server') - this.gameSocket.listen(8081) - - this.startHttpServer() + this.http.start() + this.startMetrics() /** * Register any http functionality in your own server @@ -276,37 +355,59 @@ export default abstract class Server { if (this.onHttpCallback) { this.onHttpCallback(this.httpServer) } + + // Last, because the client answers every page request the routes above leave + const servesClient = this.http.serveClient() && !!this.http.server + if (servesClient) { + // geckos matches its /.wrtc/v2 routes anywhere in the path, so this also answers a client + // that connects through a /socket prefix, like the template does + this.gameSocket.addServer(this.http.server!) + this.logger.info('Game socket reachable on the HTTP server') + } + + const socketPort = this.options.socketPort ?? (servesClient ? false : 8081) + if (socketPort !== false) { + this.logger.info('Starting @mavon/engine - Socket Server') + this.gameSocket.listen(socketPort) + } } - private startHttpServer() { - this.logger.info('Starting @mavon/engine - HTTP Server') - this.httpServer.listen(8050) - this.registerHealthEndpoint() + /** + * Stop the metric sampling and logging timers + */ + stop() { + this.timers.forEach(timer => clearInterval(timer)) + this.timers = [] + } + + private startMetrics() { + const logIntervalMs = this.options.metrics?.logIntervalMs ?? 5000 - this.logger.info('Visit game server status: http://localhost:8050/api/game/health') + this.timers.push(setInterval(() => this.metrics.sample(), this.metrics.sampleIntervalMs)) + + this.timers.push(setInterval(() => this.logStats(logIntervalMs), logIntervalMs)) } - private registerHealthEndpoint() { - this.httpServer.get('/api/game/health', (_req, res) => { - // eslint-disable-next-line ts/no-this-alias - const server = this - - res.format({ - json() { - res.json({ - serverTime: new Date(), - uptime: Math.round(performance.now() / 1000), - players: (server.game.world as ServerWorld).players.length, - world: { - entities: server.game.world.entities.length, - }, - physics: { - bodies: server.game.physicsWorld?.bodies.len(), - colliders: server.game.physicsWorld?.colliders.len(), - }, - }) - }, - }) - }) + /** + * Periodic CPU / bandwidth summary. Skipped while a dashboard is fetching + * metrics, since it already shows the same numbers live. + */ + private logStats(logIntervalMs: number) { + const watching = this.http.metricsClientActive() + + if (watching !== this.dashboardWatching) { + this.dashboardWatching = watching + this.logger.info(watching + ? 'Metrics dashboard connected, pausing CPU / bandwidth log output' + : 'Metrics dashboard disconnected, resuming CPU / bandwidth log output') + } + + if (watching) + return + + const { sent, received } = this.bandwidthTracker.getTotalSince(logIntervalMs) + this.logger.debug(`Client - Sent: ${sent} B, Received: ${received} B`) + + this.metrics.cpu.log() } } diff --git a/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts b/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts index 139e4d4..0c1dca5 100644 --- a/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts +++ b/packages/core/src/Networking/Server/Stats/BandwidthTracker.ts @@ -1,7 +1,23 @@ +export interface BandwidthSample { + timestamp: number + /** Bytes sent since the previous sample */ + sent: number + /** Bytes received since the previous sample */ + received: number +} + export default class BandwidthTracker { private sent = new Map() private received = new Map() + /** + * Rolling history of samples, oldest first. + * Each sample holds the bytes counted between two calls to sample(). + */ + readonly history: BandwidthSample[] = [] + + constructor(private historyLength = 60) { } + recordSent(id: string, bytes: number) { this.sent.set(id, (this.sent.get(id) || 0) + bytes) } @@ -17,6 +33,35 @@ export default class BandwidthTracker { } } + /** + * Push the counters for `id` into the history and reset all counters. + */ + sample(id: string, now = Date.now()): BandwidthSample { + const sample = { timestamp: now, ...this.getBandwidthUsage(id) } + + this.history.push(sample) + if (this.history.length > this.historyLength) + this.history.splice(0, this.history.length - this.historyLength) + + this.reset() + + return sample + } + + /** + * Total bytes over the samples taken within the last `duration` ms. + */ + getTotalSince(duration: number, now = Date.now()) { + const fromTime = now - duration + + return this.history + .filter(sample => sample.timestamp > fromTime) + .reduce((acc, sample) => ({ + sent: acc.sent + sample.sent, + received: acc.received + sample.received, + }), { sent: 0, received: 0 }) + } + reset() { this.sent.clear() this.received.clear() diff --git a/packages/core/src/Networking/Server/Stats/CpuTracker.ts b/packages/core/src/Networking/Server/Stats/CpuTracker.ts index af697a3..2bcf4d0 100644 --- a/packages/core/src/Networking/Server/Stats/CpuTracker.ts +++ b/packages/core/src/Networking/Server/Stats/CpuTracker.ts @@ -2,15 +2,30 @@ import type winston from 'winston' import os from 'node:os' import process from 'node:process' -interface CpuStats { +export interface CpuHistoryEntry { timestamp: number cpuPercentage: number } +export interface CpuStats { + timestamp: number + /** ms of user CPU time since the previous sample */ + userCPU: number + /** ms of system CPU time since the previous sample */ + systemCPU: number + totalUsedCPU: number + /** Percentage of a single core used since the previous sample */ + cpuPercentage: number + avg1s: number + avg10s: number + avg60s: number +} + export default class CpuTracker { private lastCpuUsage = process.cpuUsage() private lastSystemCpuInfo = this.getSystemCpuInfo() - private history: CpuStats[] = [] + private _history: CpuHistoryEntry[] = [] + private _latest?: CpuStats private logger: winston.Logger @@ -18,13 +33,28 @@ export default class CpuTracker { this.logger = logger } + get history(): readonly CpuHistoryEntry[] { + return this._history + } + + get latest(): CpuStats | undefined { + return this._latest + } + + /** + * Logs the most recent sample, taking one first if none exists yet. + * Does not take a new sample so it can't skew a sampling interval running alongside it. + */ log() { - const cpuStats = this.getCpuStats() + const cpuStats = this._latest ?? this.sample() this.logger.info(`CPU Usage - User: ${cpuStats.userCPU.toFixed(2)} ms, System: ${cpuStats.systemCPU.toFixed(2)} ms, Total 1s: ${cpuStats.avg1s}%, Total 10s: ${cpuStats.avg10s}%, Total 1m: ${cpuStats.avg60s}%`) } - private getCpuStats() { + /** + * Measure CPU usage since the previous sample and add it to the history. + */ + sample(): CpuStats { const now = Date.now() const currentCpuUsage = process.cpuUsage(this.lastCpuUsage) @@ -41,14 +71,16 @@ export default class CpuTracker { const cpuCount = os.cpus().length const maxPossibleCpuTime = totalSystemDelta / cpuCount - const cpuPercentage = (totalUsedCPU / maxPossibleCpuTime) * 100 + // os.cpus() times only tick every few ms, so two samples close together can have no delta + const cpuPercentage = maxPossibleCpuTime > 0 ? (totalUsedCPU / maxPossibleCpuTime) * 100 : 0 const roundedCpuPercentage = Number.parseFloat(cpuPercentage.toFixed(2)) // Update history and clean up old entries - this.history.push({ timestamp: now, cpuPercentage: roundedCpuPercentage }) + this._history.push({ timestamp: now, cpuPercentage: roundedCpuPercentage }) this.cleanupHistory(now) - return { + this._latest = { + timestamp: now, userCPU, systemCPU, totalUsedCPU, @@ -57,6 +89,8 @@ export default class CpuTracker { avg10s: this.getAverage(now, 10000), avg60s: this.getAverage(now, 60000), } + + return this._latest } private getSystemCpuInfo() { @@ -81,12 +115,12 @@ export default class CpuTracker { private cleanupHistory(now: number) { const oneMinuteAgo = now - 60000 - this.history = this.history.filter(stat => stat.timestamp >= oneMinuteAgo) + this._history = this._history.filter(stat => stat.timestamp >= oneMinuteAgo) } private getAverage(now: number, duration: number): number { const fromTime = now - duration - const relevantStats = this.history.filter(stat => stat.timestamp >= fromTime) + const relevantStats = this._history.filter(stat => stat.timestamp >= fromTime) if (relevantStats.length === 0) return 0 diff --git a/packages/core/src/Networking/Server/Stats/LogBuffer.ts b/packages/core/src/Networking/Server/Stats/LogBuffer.ts new file mode 100644 index 0000000..afc651d --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/LogBuffer.ts @@ -0,0 +1,68 @@ +import type winston from 'winston' +import { Writable } from 'node:stream' +import { stripVTControlCharacters } from 'node:util' +import { transports } from 'winston' + +export interface LogEntry { + /** Increases by one per entry, so clients can ask for what they haven't seen */ + id: number + timestamp: number + level: string + message: string +} + +export interface LogsResponse { + entries: LogEntry[] + /** Id of the newest entry, 0 when nothing was logged yet */ + latestId: number +} + +/** + * Keeps the most recent log entries of a winston logger in memory + * so they can be served to the dashboard. + */ +export default class LogBuffer { + private entries: LogEntry[] = [] + private nextId = 1 + + /** + * Winston transport that feeds this buffer. It has no level of its own, + * so it records whatever the logger's level lets through. + */ + readonly transport: winston.transport + + constructor(private capacity = 500) { + this.transport = new transports.Stream({ + stream: new Writable({ + objectMode: true, + write: (info: winston.Logform.TransformableInfo, _encoding, callback) => { + this.push(info.level, info.message) + callback() + }, + }), + }) + } + + push(level: unknown, message: unknown, now = Date.now()) { + this.entries.push({ + id: this.nextId++, + timestamp: now, + // Formats like colorize() add ANSI codes, which the dashboard draws itself + level: stripVTControlCharacters(String(level)), + message: stripVTControlCharacters(typeof message === 'string' ? message : JSON.stringify(message) ?? String(message)), + }) + + if (this.entries.length > this.capacity) + this.entries.splice(0, this.entries.length - this.capacity) + } + + /** + * Entries newer than `afterId`, oldest first. + */ + since(afterId = 0): LogsResponse { + return { + entries: this.entries.filter(entry => entry.id > afterId), + latestId: this.nextId - 1, + } + } +} diff --git a/packages/core/src/Networking/Server/Stats/RequestLog.ts b/packages/core/src/Networking/Server/Stats/RequestLog.ts new file mode 100644 index 0000000..49ab5a3 --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/RequestLog.ts @@ -0,0 +1,139 @@ +import type { IncomingMessage, Server as NodeServer, ServerResponse } from 'node:http' +import type { Duplex } from 'node:stream' +import { Buffer } from 'node:buffer' +import { performance } from 'node:perf_hooks' + +export interface RequestLogEntry { + /** Increases by one per entry, so clients can ask for what they haven't seen */ + id: number + /** When the request arrived */ + timestamp: number + /** HTTP method, or `WS` for a WebSocket upgrade */ + method: string + /** Path without the query string, which can hold tokens */ + path: string + /** Response status, null when the connection closed before one was sent */ + status: number | null + /** Until the response finished, or until the handshake answer for a WebSocket */ + durationMs: number + /** Bytes written to the connection for this response, headers included */ + bytes: number + address: string | null +} + +export interface RequestLogResponse { + entries: RequestLogEntry[] + /** Id of the newest entry, 0 when nothing was recorded yet */ + latestId: number +} + +/** Requests the dashboard makes every poll, which would bury everything else */ +export const UNLOGGED_PATHS = new Set(['/api/game/metrics', '/api/game/logs', '/api/game/requests']) + +const STATUS_LINE_RE = /^HTTP\/\d(?:\.\d)? (\d{3})/ + +/** + * Keeps the most recent requests to a Node HTTP server in memory so they can be served to the dashboard. + * It listens on the server itself rather than inside Express, so static files, the Vite dev server, + * geckos.io signaling and WebSocket upgrades show up too. + */ +export default class RequestLog { + private entries: RequestLogEntry[] = [] + private nextId = 1 + + constructor(private capacity = 500) { } + + /** + * Record every request and WebSocket upgrade `server` receives from now on. + */ + attach(server: NodeServer) { + // First in line, so nothing has been written yet when a request is picked up + server.prependListener('request', (req: IncomingMessage, res: ServerResponse) => this.onRequest(req, res)) + server.prependListener('upgrade', (req: IncomingMessage, socket: Duplex) => { + this.onUpgrade(req, socket) + + // With no other listener, Node would have closed the connection. Listening must not change that + if (server.listenerCount('upgrade') === 1) + socket.destroy() + }) + } + + push(entry: Omit) { + this.entries.push({ id: this.nextId++, ...entry }) + + if (this.entries.length > this.capacity) + this.entries.splice(0, this.entries.length - this.capacity) + } + + /** + * Entries newer than `afterId`, oldest first. + */ + since(afterId = 0): RequestLogResponse { + return { + entries: this.entries.filter(entry => entry.id > afterId), + latestId: this.nextId - 1, + } + } + + private onRequest(req: IncomingMessage, res: ServerResponse) { + const path = pathOf(req) + if (UNLOGGED_PATHS.has(path)) + return + + const timestamp = Date.now() + const start = performance.now() + // Keep-alive connections carry one request after another, so the difference is this response + const written = req.socket.bytesWritten + + res.once('close', () => this.push({ + timestamp, + method: req.method ?? '?', + path, + status: res.headersSent ? res.statusCode : null, + durationMs: performance.now() - start, + bytes: req.socket.bytesWritten - written, + address: req.socket.remoteAddress ?? null, + })) + } + + /** + * Upgrades are answered by writing straight to the socket, so the first write carries the status line. + */ + private onUpgrade(req: IncomingMessage, socket: Duplex) { + const timestamp = Date.now() + const start = performance.now() + let recorded = false + const write = socket.write + + const record = (status: number | null, bytes: number) => { + if (recorded) + return + recorded = true + socket.write = write + this.push({ + timestamp, + method: 'WS', + path: pathOf(req), + status, + durationMs: performance.now() - start, + bytes, + address: req.socket.remoteAddress ?? null, + }) + } + + socket.write = function (this: Duplex, chunk: any, ...rest: any[]) { + const head = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1', 0, 32) : '' + const status = STATUS_LINE_RE.exec(head)?.[1] + record(status ? Number(status) : null, typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk?.length ?? 0) + return write.apply(this, [chunk, ...rest] as Parameters) + } as Duplex['write'] + + socket.once('close', () => record(null, 0)) + } +} + +function pathOf(req: IncomingMessage) { + const url = req.url ?? '/' + const query = url.indexOf('?') + return query === -1 ? url : url.slice(0, query) +} diff --git a/packages/core/src/Networking/Server/Stats/ServerMetrics.ts b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts new file mode 100644 index 0000000..294a784 --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/ServerMetrics.ts @@ -0,0 +1,218 @@ +import type winston from 'winston' +import type BaseGame from '../../../BaseGame' +import type { PlayerConnectionInfo } from '../../Entities/Player' +import type { LatencySimulatorOptions } from '../LatencySimulator' +import type ServerWorld from '../World' +import type { BandwidthSample } from './BandwidthTracker' +import type { CpuHistoryEntry } from './CpuTracker' +import type { TickSample } from './TickTracker' +import process from 'node:process' +import { version as ENGINE_VERSION } from '../../../version' +import BandwidthTracker from './BandwidthTracker' +import CpuTracker from './CpuTracker' +import TickTracker from './TickTracker' + +/** + * Bumped whenever a field is removed or changes meaning. + * Consumers (e.g. the mavonengine CLI dashboard) should tolerate unknown fields. + */ +export const METRICS_SCHEMA_VERSION = 1 + +export interface PlayerMetrics { + id: string + displayName: string + position: { x: number, y: number, z: number } + lastProcessedSequenceId: number + trackedEntities: number + /** Seconds since the player connected */ + connectedFor: number + /** Null when the player has no live connection, e.g. a bot */ + connection: PlayerConnectionInfo | null +} + +export interface ServerHealth { + serverTime: string + uptime: number + players: number + world: { + entities: number + } + physics: { + bodies?: number + colliders?: number + } +} + +export interface ServerMetricsSnapshot { + schemaVersion: typeof METRICS_SCHEMA_VERSION + engineVersion: string + serverTime: string + /** Seconds since the process started */ + uptime: number + /** Ms between metric samples, i.e. the width of each history bucket */ + sampleIntervalMs: number + tickRate: number + players: PlayerMetrics[] + world: { + entities: number + } + physics: { + bodies: number + colliders: number + } | null + cpu: { + current: number + avg1s: number + avg10s: number + avg60s: number + history: CpuHistoryEntry[] + } + bandwidth: { + sentPerSecond: number + receivedPerSecond: number + history: BandwidthSample[] + } + tick: { + budgetMs: number + latest: TickSample | null + history: TickSample[] + } + memory: { + rss: number + heapUsed: number + heapTotal: number + external: number + } + latencySimulation: Required | null +} + +export interface ServerMetricsOptions { + /** How often trackers are sampled, in ms. Default 1000 */ + sampleIntervalMs?: number + /** How many samples each tracker keeps. Default 60 */ + historyLength?: number +} + +export interface ServerMetricsSource { + game: BaseGame + latencySimulation?: Readonly> +} + +/** + * Bandwidth is tracked under a single id for the whole server. + */ +export const SERVER_BANDWIDTH_ID = 'server' + +/** + * Collects server statistics on a fixed interval and builds snapshots of them. + */ +export default class ServerMetrics { + readonly cpu: CpuTracker + readonly bandwidth: BandwidthTracker + readonly tick: TickTracker + + readonly sampleIntervalMs: number + + constructor( + private source: ServerMetricsSource, + logger: winston.Logger, + { sampleIntervalMs = 1000, historyLength = 60 }: ServerMetricsOptions = {}, + ) { + this.sampleIntervalMs = sampleIntervalMs + this.cpu = new CpuTracker(logger) + this.bandwidth = new BandwidthTracker(historyLength) + this.tick = new TickTracker(historyLength) + } + + /** + * Close the current sampling window on every tracker. + */ + sample(now = Date.now()) { + this.cpu.sample() + this.bandwidth.sample(SERVER_BANDWIDTH_ID, now) + this.tick.sample(now) + } + + health(): ServerHealth { + const { game } = this.source + + return { + serverTime: new Date().toISOString(), + uptime: Math.round(performance.now() / 1000), + players: this.players().length, + world: { + entities: game.world.entities.length, + }, + physics: { + bodies: game.physicsWorld?.bodies.len(), + colliders: game.physicsWorld?.colliders.len(), + }, + } + } + + snapshot(): ServerMetricsSnapshot { + const { game, latencySimulation } = this.source + const cpu = this.cpu.latest + const lastBandwidth = this.bandwidth.history.at(-1) + const perSecond = 1000 / this.sampleIntervalMs + const memory = process.memoryUsage() + + return { + schemaVersion: METRICS_SCHEMA_VERSION, + engineVersion: ENGINE_VERSION, + serverTime: new Date().toISOString(), + uptime: Math.round(performance.now() / 1000), + sampleIntervalMs: this.sampleIntervalMs, + tickRate: game.tickRate, + players: this.players().map(player => ({ + id: player.id, + displayName: player.displayName, + position: { x: player.position.x, y: player.position.y, z: player.position.z }, + lastProcessedSequenceId: player.lastProcessedSequenceId, + trackedEntities: player.trackedEntities.size, + connectedFor: Math.max(0, Math.round((Date.now() - player.connectedAt) / 1000)), + connection: player.connection, + })), + world: { + entities: game.world.entities.length, + }, + physics: game.physicsWorld + ? { + bodies: game.physicsWorld.bodies.len(), + colliders: game.physicsWorld.colliders.len(), + } + : null, + cpu: { + current: cpu?.cpuPercentage ?? 0, + avg1s: cpu?.avg1s ?? 0, + avg10s: cpu?.avg10s ?? 0, + avg60s: cpu?.avg60s ?? 0, + history: [...this.cpu.history], + }, + bandwidth: { + sentPerSecond: Math.round((lastBandwidth?.sent ?? 0) * perSecond), + receivedPerSecond: Math.round((lastBandwidth?.received ?? 0) * perSecond), + history: [...this.bandwidth.history], + }, + tick: { + budgetMs: Math.round((1000 / game.tickRate) * 100) / 100, + latest: this.tick.latest ?? null, + history: [...this.tick.history], + }, + memory: { + rss: memory.rss, + heapUsed: memory.heapUsed, + heapTotal: memory.heapTotal, + external: memory.external, + }, + latencySimulation: latencySimulation ? { ...latencySimulation } : null, + } + } + + /** + * Players that haven't been marked for removal yet. + */ + private players() { + return (this.source.game.world as ServerWorld).players.filter(player => !player.garbageCollect) + } +} diff --git a/packages/core/src/Networking/Server/Stats/TickTracker.ts b/packages/core/src/Networking/Server/Stats/TickTracker.ts new file mode 100644 index 0000000..53c162d --- /dev/null +++ b/packages/core/src/Networking/Server/Stats/TickTracker.ts @@ -0,0 +1,79 @@ +export interface TickSample { + timestamp: number + /** Number of ticks run since the previous sample */ + ticks: number + /** Ticks per second over the sample window */ + ticksPerSecond: number + /** Average time spent inside a tick in ms */ + avgMs: number + /** Slowest tick in ms */ + maxMs: number + /** Client commands processed per second */ + commandsPerSecond: number + /** Most commands processed in a single tick */ + maxCommandsPerTick: number +} + +export default class TickTracker { + private ticks = 0 + private totalMs = 0 + private maxMs = 0 + private commands = 0 + private maxCommandsPerTick = 0 + private lastSampleTime: number + + /** + * Rolling history of samples, oldest first. + */ + readonly history: TickSample[] = [] + + constructor(private historyLength = 60, now = Date.now()) { + this.lastSampleTime = now + } + + record(durationMs: number, commandsProcessed = 0) { + this.ticks++ + this.totalMs += durationMs + this.maxMs = Math.max(this.maxMs, durationMs) + this.commands += commandsProcessed + this.maxCommandsPerTick = Math.max(this.maxCommandsPerTick, commandsProcessed) + } + + /** + * Summarise the ticks recorded since the previous sample, add it to the history and reset the counters. + */ + sample(now = Date.now()): TickSample { + const elapsedSeconds = Math.max(now - this.lastSampleTime, 1) / 1000 + + const sample: TickSample = { + timestamp: now, + ticks: this.ticks, + ticksPerSecond: round(this.ticks / elapsedSeconds), + avgMs: this.ticks > 0 ? round(this.totalMs / this.ticks) : 0, + maxMs: round(this.maxMs), + commandsPerSecond: round(this.commands / elapsedSeconds), + maxCommandsPerTick: this.maxCommandsPerTick, + } + + this.history.push(sample) + if (this.history.length > this.historyLength) + this.history.splice(0, this.history.length - this.historyLength) + + this.ticks = 0 + this.totalMs = 0 + this.maxMs = 0 + this.commands = 0 + this.maxCommandsPerTick = 0 + this.lastSampleTime = now + + return sample + } + + get latest(): TickSample | undefined { + return this.history.at(-1) + } +} + +function round(value: number) { + return Math.round(value * 100) / 100 +} diff --git a/packages/core/src/Utils/BootBanner.ts b/packages/core/src/Utils/BootBanner.ts new file mode 100644 index 0000000..d7d14b6 --- /dev/null +++ b/packages/core/src/Utils/BootBanner.ts @@ -0,0 +1,31 @@ +export interface BootBannerInfo { + version: string + /** Three.js revision, e.g. '180' */ + threeRevision?: string + rapierVersion?: string + mode: string +} + +/** + * The box logged when a game starts. Kept free of imports so tools like @mavonengine/cli can show it + * without loading the engine. + */ +export function bootBanner({ version, threeRevision, rapierVersion, mode }: BootBannerInfo): string[] { + const row = (label: string, value: string) => `│ ${`${label}:`.padEnd(10)}${value.padEnd(28)} │` + + return [ + '┌─────────────────────────────────────────┐', + '│ │', + '│ MavonEngine │', + '│ │', + row('Version', version), + ...(threeRevision ? [row('Three.js', `r${threeRevision}`)] : []), + ...(rapierVersion ? [row('Rapier', rapierVersion)] : []), + row('Mode', mode), + '│ │', + '│ https://github.com/MavonEngine/core │', + '│ https://mavonengine.com │', + '│ │', + '└─────────────────────────────────────────┘', + ] +} diff --git a/packages/core/tests/Networking/Entities/Player.test.ts b/packages/core/tests/Networking/Entities/Player.test.ts new file mode 100644 index 0000000..f8401af --- /dev/null +++ b/packages/core/tests/Networking/Entities/Player.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import Player from '../../../src/Networking/Entities/Player' + +class TestPlayer extends Player { + health = 100 + maxHealth = 100 + takeDamage(_amount: number): void { } + heal(_amount: number): void { } + update(_delta: number): void { } +} + +function withPeer(overrides: Record = {}) { + const player = new TestPlayer('p1') + player.channel = { webrtcConnection: { peerConnection: { + rtt: () => 35, + bytesSent: () => 5000, + bytesReceived: () => 700, + getSelectedCandidatePair: () => ({ remote: { address: '198.51.100.4' } }), + ...overrides, + } } } as any + return player +} + +describe('player', () => { + it('uses the id as the display name until one is set', () => { + const player = new TestPlayer('p1') + expect(player.displayName).toBe('p1') + + player.setDisplayName('Alice') + expect(player.displayName).toBe('Alice') + + player.setDisplayName(null) + expect(player.displayName).toBe('p1') + }) + + it('has no connection without a channel', () => { + expect(new TestPlayer('bot').connection).toBeNull() + }) + + it('reads its connection from the peer connection', () => { + expect(withPeer().connection).toEqual({ + address: '198.51.100.4', + rttMs: 35, + bytesSent: 5000, + bytesReceived: 700, + }) + }) + + it('leaves out what the peer connection does not know yet', () => { + const player = withPeer({ rtt: () => -1, getSelectedCandidatePair: () => null }) + expect(player.connection).toMatchObject({ address: null, rttMs: null }) + }) + + it('survives a closed peer connection', () => { + const player = withPeer({ + rtt: () => { + throw new Error('closed') + }, + }) + expect(player.connection).toEqual({ address: null, rttMs: null, bytesSent: 0, bytesReceived: 0 }) + }) +}) diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index 739714a..abb4bb8 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -1,6 +1,6 @@ import type { ServerChannel } from '@geckos.io/server' import type ServerWorld from '../../src/Networking/Server/World' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import winston from 'winston' import Player from '../../src/Networking/Entities/Player' import { ServerCommand } from '../../src/Networking/Server/Commands' @@ -78,6 +78,7 @@ describe('server', () => { expect((server.game.world as ServerWorld).players).toHaveLength(1) expect((server.game.world as ServerWorld).players[0].id).eq(randomId) + expect((server.game.world as ServerWorld).players[0].channel?.id).eq(randomId) // Simulate a disconnect from the client disconnectCallback() @@ -181,6 +182,217 @@ describe('server', () => { expect(player.lastProcessedSequenceId).toBe(4) }) + it('skips commands from players that disconnected before they were processed', () => { + const server = new TestServer(logger) + + const onCommand = vi.fn() + ;(server as any).onCommand = onCommand + ;(server as any).commandBuffer = [{ sequenceId: 1, playerId: 'gone' }] + + expect(() => (server as any).runThroughBuffer()).not.toThrow() + expect(onCommand).not.toHaveBeenCalled() + }) + + it('never moves lastProcessedSequenceId backwards', () => { + const server = new TestServer(logger) + + const player = new TestClient('p1') + player.updateLastProcessedSequenceId(10) + server.game.world.entities.items.set('p1', player) + + ;(server as any).commandBuffer = [{ sequenceId: 4, playerId: 'p1' }] + ;(server as any).runThroughBuffer() + + expect(player.lastProcessedSequenceId).toBe(10) + }) + + it('passes the same delta to every command in a tick and resets it afterwards', () => { + const server = new TestServer(logger) + server.game.world.entities.items.set('p1', new TestClient('p1')) + + const deltas: number[] = [] + ;(server as any).onCommand = (_cmd: unknown, delta: number) => deltas.push(delta) + + server.lastTime = performance.now() - 1000 + ;(server as any).commandBuffer = [ + { sequenceId: 1, playerId: 'p1' }, + { sequenceId: 2, playerId: 'p1' }, + ] + + expect((server as any).runThroughBuffer()).toBe(2) + expect(deltas[0]).toBeGreaterThanOrEqual(1000) + expect(deltas[1]).toBe(deltas[0]) + expect((server as any).calculateDelta()).toBeLessThan(1000) + }) + + it('records tick duration and processed commands when a tick completes', () => { + const server = new TestServer(logger) + server.game.world.entities.items.set('p1', new TestClient('p1')) + server.getStateSyncDistance = () => 100 + + ;(server as any).commandBuffer = [{ sequenceId: 1, playerId: 'p1' }] + server.game.tick() + + expect(server.metrics.tick.sample()).toMatchObject({ ticks: 1, maxCommandsPerTick: 1 }) + }) + + it('exposes the latency simulation config to metrics', () => { + const server = new TestServer(logger, undefined, { latencySimulation: { fixedLatency: 20 } }) + + expect(server.metrics.snapshot().latencySimulation).toEqual({ fixedLatency: 20, jitter: 0, packetLoss: 0 }) + }) + + it('listens on the configured ports', () => { + const server = new TestServer(logger, undefined, { socketPort: 9001, httpPort: 9002 }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const httpStart = vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(9001) + expect(httpStart).toHaveBeenCalled() + expect((server.http as any).port).toBe(9002) + }) + + it('moves the game socket onto the HTTP server when it serves the client', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' } }) + const nodeServer = {} + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(true) + vi.spyOn(server.http, 'server', 'get').mockReturnValue(nodeServer as any) + + server.start() + server.stop() + + expect(addServer).toHaveBeenCalledWith(nodeServer) + expect(listen).not.toHaveBeenCalled() + }) + + it('still opens the socket port when asked to while serving the client', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' }, socketPort: 9001 }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + ;(server.gameSocket as any).addServer = vi.fn() + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(true) + vi.spyOn(server.http, 'server', 'get').mockReturnValue({} as any) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(9001) + }) + + it('falls back to port 8081 when the client is configured but not built', () => { + const server = new TestServer(logger, undefined, { client: { root: '/game/client' } }) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + vi.spyOn(server.http, 'serveClient').mockReturnValue(false) + + server.start() + server.stop() + + expect(listen).toHaveBeenCalledWith(8081) + expect(addServer).not.toHaveBeenCalled() + }) + + it('keeps the game socket on its own port when no client is served', () => { + const server = new TestServer(logger) + + const listen = vi.fn() + ;(server.gameSocket as any).listen = listen + const addServer = vi.fn() + ;(server.gameSocket as any).addServer = addServer + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + server.start() + server.stop() + + expect(addServer).not.toHaveBeenCalled() + expect(listen).toHaveBeenCalledWith(8081) + }) + + it('captures log output for the logs endpoint', () => { + const logger = winston.createLogger({ level: 'info', transports: [] }) + const server = new TestServer(logger) + + logger.info('from the game') + + expect(server.logs!.since().entries.at(-1)).toMatchObject({ level: 'info', message: 'from the game' }) + }) + + it('does not capture logs when logBufferSize is 0', () => { + const logger = winston.createLogger({ transports: [] }) + const server = new TestServer(logger, undefined, { metrics: { logBufferSize: 0 } }) + + expect(server.logs).toBeUndefined() + expect(logger.transports).toHaveLength(0) + }) + + it('records HTTP requests unless requestLogSize is 0', () => { + expect(new TestServer(logger).requests).toBeDefined() + expect(new TestServer(logger, undefined, { metrics: { requestLogSize: 0 } }).requests).toBeUndefined() + }) + + describe('periodic stats logging', () => { + function startServer() { + const server = new TestServer(logger, undefined, { metrics: { logIntervalMs: 1000 } }) + ;(server.gameSocket as any).listen = vi.fn() + vi.spyOn(server.http, 'start').mockImplementation(() => {}) + + const cpuLog = vi.spyOn(server.metrics.cpu, 'log').mockImplementation(() => {}) + const info = vi.spyOn(logger, 'info') + + server.start() + return { server, cpuLog, info } + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('logs CPU / bandwidth when no dashboard is watching', () => { + vi.useFakeTimers() + const { server, cpuLog } = startServer() + + vi.advanceTimersByTime(1000) + server.stop() + + expect(cpuLog).toHaveBeenCalledTimes(1) + }) + + it('pauses while a dashboard is fetching metrics and says so once', () => { + vi.useFakeTimers() + const { server, cpuLog, info } = startServer() + const active = vi.spyOn(server.http, 'metricsClientActive').mockReturnValue(true) + + vi.advanceTimersByTime(3000) + + expect(cpuLog).not.toHaveBeenCalled() + expect(info.mock.calls.filter(([msg]) => String(msg).includes('dashboard connected'))).toHaveLength(1) + + active.mockReturnValue(false) + vi.advanceTimersByTime(1000) + server.stop() + + expect(cpuLog).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledWith(expect.stringContaining('dashboard disconnected')) + }) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts b/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts new file mode 100644 index 0000000..c34ba3b --- /dev/null +++ b/packages/core/tests/Networking/Server/Http/ClientMiddleware.test.ts @@ -0,0 +1,112 @@ +import type { Server as NodeServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import HttpServer from '../../../../src/Networking/Server/Http/HttpServer' + +const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } + +let dir: string +let http: HttpServer + +async function serve(options: HttpServerOptions = {}) { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any + const metrics = { health: () => health, snapshot: () => ({}) } as any + + http = new HttpServer(logger, metrics, { port: 0, ...options }) + http.start() + const listener = http.server as NodeServer + await new Promise(resolve => listener.once('listening', resolve)) + + const served = http.serveClient() + const { port } = listener.address() as AddressInfo + + return { + served, + logger, + request: (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${port}${path}`, init), + } +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mavonengine-client-')) +}) + +afterEach(async () => { + await new Promise(resolve => http.server?.close(resolve)) + rmSync(dir, { recursive: true, force: true }) +}) + +describe('serveClient', () => { + it('serves nothing without client options', async () => { + const { served, request } = await serve() + + expect(served).toBe(false) + expect((await request('/')).status).toBe(404) + }) + + describe('built client', () => { + beforeEach(() => { + mkdirSync(join(dir, 'dist', 'assets'), { recursive: true }) + writeFileSync(join(dir, 'dist', 'index.html'), 'game') + writeFileSync(join(dir, 'dist', 'assets', 'app.js'), 'console.log(1)') + }) + + it('serves files from /dist', async () => { + const { served, request } = await serve({ client: { root: dir } }) + + expect(served).toBe(true) + expect(await (await request('/assets/app.js')).text()).toBe('console.log(1)') + }) + + it('falls back to index.html for page routes', async () => { + const { request } = await serve({ client: { root: dir } }) + + const res = await request('/lobby/42', { headers: { Accept: 'text/html' } }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('game') + }) + + it('leaves missing assets and api routes alone', async () => { + const { request } = await serve({ client: { root: dir } }) + + expect((await request('/assets/missing.js')).status).toBe(404) + expect((await request('/src/main.ts', { headers: { Accept: 'text/html' } })).status).toBe(404) + expect((await request('/api/nope', { headers: { Accept: 'text/html' } })).status).toBe(404) + expect(await (await request('/api/game/health')).json()).toEqual(health) + }) + + it('uses a custom dist directory', async () => { + mkdirSync(join(dir, 'out')) + writeFileSync(join(dir, 'out', 'index.html'), 'out') + const { request } = await serve({ client: { root: dir, dist: join(dir, 'out') } }) + + expect(await (await request('/')).text()).toBe('out') + }) + }) + + it('warns when the client has not been built', async () => { + const { served, logger } = await serve({ client: { root: dir } }) + + expect(served).toBe(false) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No built client')) + }) + + it('serves the client source through Vite in dev mode', async () => { + writeFileSync(join(dir, 'index.html'), '') + writeFileSync(join(dir, 'main.js'), 'export const answer = 42') + const { served, request } = await serve({ client: { root: dir, dev: true } }) + + expect(served).toBe(true) + + const page = await (await request('/', { headers: { Accept: 'text/html' } })).text() + expect(page).toContain('/@vite/client') + + expect(await (await request('/main.js')).text()).toContain('answer = 42') + expect(await (await request('/api/game/health')).json()).toEqual(health) + }, 20000) +}) diff --git a/packages/core/tests/Networking/Server/Http/HttpServer.test.ts b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts new file mode 100644 index 0000000..04ed2d2 --- /dev/null +++ b/packages/core/tests/Networking/Server/Http/HttpServer.test.ts @@ -0,0 +1,154 @@ +import type { Server as NodeServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { HttpServerOptions } from '../../../../src/Networking/Server/Http/HttpServer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import HttpServer, { METRICS_CLIENT_TIMEOUT_MS } from '../../../../src/Networking/Server/Http/HttpServer' +import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' +import RequestLog from '../../../../src/Networking/Server/Stats/RequestLog' + +const health = { serverTime: 'now', uptime: 1, players: 0, world: { entities: 0 }, physics: {} } +const snapshot = { schemaVersion: 1, players: [] } + +let listener: NodeServer | undefined + +let http: HttpServer + +async function serve(options: HttpServerOptions = {}) { + const logger = { info: vi.fn() } as any + const metrics = { health: () => health, snapshot: () => snapshot } as any + + http = new HttpServer(logger, metrics, options) + + listener = await new Promise((resolve) => { + const server = http.app.listen(0, '127.0.0.1', () => resolve(server)) + }) + + const { port } = listener.address() as AddressInfo + + return (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${port}${path}`, init) +} + +describe('httpServer', () => { + afterEach(() => { + listener?.close() + listener = undefined + vi.unstubAllEnvs() + }) + + it('serves health', async () => { + const request = await serve() + + const res = await request('/api/game/health') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(health) + }) + + it('serves metrics to localhost without a token', async () => { + const request = await serve() + + const res = await request('/api/game/metrics') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(snapshot) + }) + + it('requires the token when one is configured', async () => { + const request = await serve({ metrics: { token: 'secret' } }) + + expect((await request('/api/game/metrics')).status).toBe(401) + expect((await request('/api/game/metrics?token=wrong')).status).toBe(401) + expect((await request('/api/game/metrics?token=secret')).status).toBe(200) + + const res = await request('/api/game/metrics', { headers: { Authorization: 'Bearer secret' } }) + expect(res.status).toBe(200) + }) + + it('can be disabled', async () => { + const request = await serve({ metrics: { enabled: false } }) + + expect((await request('/api/game/metrics')).status).toBe(404) + expect((await request('/api/game/health')).status).toBe(200) + }) + + it('is disabled in production unless a token is set', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const request = await serve() + expect((await request('/api/game/metrics')).status).toBe(404) + + listener?.close() + + const withToken = await serve({ metrics: { token: 'secret' } }) + expect((await withToken('/api/game/metrics?token=secret')).status).toBe(200) + }) + + it('serves logs after an id', async () => { + const logs = new LogBuffer() + logs.push('info', 'one') + logs.push('warn', 'two') + + const request = await serve({ logs }) + + const all = await (await request('/api/game/logs')).json() + expect(all.latestId).toBe(2) + expect(all.entries.map((e: any) => e.message)).toEqual(['one', 'two']) + + const newer = await (await request('/api/game/logs?after=1')).json() + expect(newer.entries.map((e: any) => e.message)).toEqual(['two']) + + expect((await (await request('/api/game/logs?after=nope')).json()).entries).toHaveLength(2) + }) + + it('protects logs like metrics', async () => { + const request = await serve({ logs: new LogBuffer(), metrics: { token: 'secret' } }) + + expect((await request('/api/game/logs')).status).toBe(401) + expect((await request('/api/game/logs?token=secret')).status).toBe(200) + }) + + it('404s logs without a buffer', async () => { + const request = await serve() + + expect((await request('/api/game/logs')).status).toBe(404) + }) + + it('serves recorded requests after an id, protected like metrics', async () => { + const requests = new RequestLog() + requests.push({ timestamp: 0, method: 'GET', path: '/one', status: 200, durationMs: 1, bytes: 10, address: null }) + requests.push({ timestamp: 0, method: 'GET', path: '/two', status: 404, durationMs: 1, bytes: 10, address: null }) + + const request = await serve({ requests, metrics: { token: 'secret' } }) + + expect((await request('/api/game/requests')).status).toBe(401) + + const newer = await (await request('/api/game/requests?after=1&token=secret')).json() + expect(newer.latestId).toBe(2) + expect(newer.entries.map((e: any) => e.path)).toEqual(['/two']) + }) + + it('404s requests without a request log', async () => { + const request = await serve() + + expect((await request('/api/game/requests')).status).toBe(404) + }) + + it('tracks whether metrics were fetched recently', async () => { + const request = await serve() + + expect(http.metricsClientActive()).toBe(false) + + await request('/api/game/metrics') + + expect(http.metricsClientActive()).toBe(true) + expect(http.metricsClientActive(Date.now() + METRICS_CLIENT_TIMEOUT_MS + 1)).toBe(false) + }) + + it('does not count rejected metrics requests', async () => { + const request = await serve({ metrics: { token: 'secret' } }) + + await request('/api/game/metrics') + + expect(http.metricsClientActive()).toBe(false) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts b/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts index e45bef9..071ab26 100644 --- a/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts +++ b/packages/core/tests/Networking/Server/Stats/BandwidthTracker.test.ts @@ -13,4 +13,41 @@ describe('bandwidthTracker', () => { tracker.reset() expect(tracker.getBandwidthUsage('a')).toEqual({ sent: 0, received: 0 }) }) + + it('moves counters into the history when sampled', () => { + const tracker = new BandwidthTracker() + tracker.recordSent('a', 10) + tracker.recordReceived('a', 4) + + const sample = tracker.sample('a', 1000) + + expect(sample).toEqual({ timestamp: 1000, sent: 10, received: 4 }) + expect(tracker.history).toEqual([sample]) + expect(tracker.getBandwidthUsage('a')).toEqual({ sent: 0, received: 0 }) + }) + + it('caps the history length', () => { + const tracker = new BandwidthTracker(3) + + for (let i = 0; i < 5; i++) { + tracker.recordSent('a', i) + tracker.sample('a', i) + } + + expect(tracker.history.map(s => s.sent)).toEqual([2, 3, 4]) + }) + + it('totals the samples within a time window', () => { + const tracker = new BandwidthTracker() + + tracker.recordSent('a', 1) + tracker.sample('a', 1000) + tracker.recordSent('a', 2) + tracker.recordReceived('a', 5) + tracker.sample('a', 5000) + tracker.recordSent('a', 3) + tracker.sample('a', 6000) + + expect(tracker.getTotalSince(2000, 6000)).toEqual({ sent: 5, received: 5 }) + }) }) diff --git a/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts b/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts index 9c3d27d..d401fe8 100644 --- a/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts +++ b/packages/core/tests/Networking/Server/Stats/CpuTracker.test.ts @@ -6,7 +6,8 @@ describe('cpuTracker', () => { const logger = { info: vi.fn() } as any const tracker = new CpuTracker(logger) - vi.spyOn(tracker as any, 'getCpuStats').mockReturnValue({ + vi.spyOn(tracker, 'sample').mockReturnValue({ + timestamp: 0, userCPU: 1, systemCPU: 2, totalUsedCPU: 3, @@ -21,4 +22,27 @@ describe('cpuTracker', () => { 'CPU Usage - User: 1.00 ms, System: 2.00 ms, Total 1s: 10%, Total 10s: 20%, Total 1m: 30%', ) }) + + it('logs the latest sample instead of taking a new one', () => { + const logger = { info: vi.fn() } as any + const tracker = new CpuTracker(logger) + + const latest = tracker.sample() + const sample = vi.spyOn(tracker, 'sample') + + tracker.log() + + expect(sample).not.toHaveBeenCalled() + expect(tracker.latest).toBe(latest) + }) + + it('records each sample in the history', () => { + const tracker = new CpuTracker({ info: vi.fn() } as any) + + tracker.sample() + tracker.sample() + + expect(tracker.history).toHaveLength(2) + expect(Number.isFinite(tracker.history[1].cpuPercentage)).toBe(true) + }) }) diff --git a/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts b/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts new file mode 100644 index 0000000..02a6c9d --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/LogBuffer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import winston from 'winston' +import LogBuffer from '../../../../src/Networking/Server/Stats/LogBuffer' + +describe('logBuffer', () => { + it('records entries logged through winston', () => { + const buffer = new LogBuffer() + const logger = winston.createLogger({ level: 'info', transports: [buffer.transport] }) + + logger.info('hello') + logger.warn('careful') + logger.debug('hidden by the logger level') + + expect(buffer.since().entries.map(e => [e.id, e.level, e.message])).toEqual([ + [1, 'info', 'hello'], + [2, 'warn', 'careful'], + ]) + }) + + it('strips ANSI colors added by formats', () => { + const buffer = new LogBuffer() + const logger = winston.createLogger({ + format: winston.format.colorize({ all: true }), + transports: [buffer.transport], + }) + + logger.error('boom') + + expect(buffer.since().entries[0]).toMatchObject({ level: 'error', message: 'boom' }) + }) + + it('stringifies non-string messages', () => { + const buffer = new LogBuffer() + + buffer.push('info', { a: 1 }) + + expect(buffer.since().entries[0].message).toBe('{"a":1}') + }) + + it('returns only entries after the given id', () => { + const buffer = new LogBuffer() + buffer.push('info', 'one') + buffer.push('info', 'two') + buffer.push('info', 'three') + + expect(buffer.since(2)).toEqual({ + entries: [expect.objectContaining({ id: 3, message: 'three' })], + latestId: 3, + }) + expect(buffer.since(3)).toEqual({ entries: [], latestId: 3 }) + }) + + it('drops the oldest entries past capacity but keeps counting ids', () => { + const buffer = new LogBuffer(2) + buffer.push('info', 'one') + buffer.push('info', 'two') + buffer.push('info', 'three') + + const { entries, latestId } = buffer.since() + + expect(entries.map(e => e.id)).toEqual([2, 3]) + expect(latestId).toBe(3) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts b/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts new file mode 100644 index 0000000..1bc9460 --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/RequestLog.test.ts @@ -0,0 +1,105 @@ +import type { AddressInfo } from 'node:net' +import { createServer, request as httpRequest } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import RequestLog from '../../../../src/Networking/Server/Stats/RequestLog' + +let server: ReturnType | undefined + +async function listen(handler: Parameters[0]) { + server = createServer(handler) + const log = new RequestLog() + log.attach(server) + + await new Promise(resolve => server!.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + + return { log, port } +} + +/** Ask for a WebSocket upgrade and resolve with the status line, or null if the connection closes first */ +function upgrade(port: number, path: string) { + return new Promise((resolve) => { + const req = httpRequest({ port, host: '127.0.0.1', path, headers: { Connection: 'Upgrade', Upgrade: 'websocket' } }) + req.on('upgrade', (res, socket) => { + socket.destroy() + resolve(`${res.statusCode}`) + }) + req.on('error', () => resolve(null)) + req.on('close', () => resolve(null)) + req.end() + }) +} + +/** Lets the server side 'close' events run */ +const settle = () => new Promise(resolve => setTimeout(resolve, 20)) + +describe('requestLog', () => { + afterEach(() => { + server?.closeAllConnections() + server?.close() + server = undefined + }) + + it('records requests with status, size and timing, without the query string', async () => { + const { log, port } = await listen((req, res) => { + res.statusCode = req.url!.startsWith('/missing') ? 404 : 200 + res.end('hello') + }) + + await (await fetch(`http://127.0.0.1:${port}/index.html?token=secret`)).text() + await (await fetch(`http://127.0.0.1:${port}/missing`, { method: 'POST' })).text() + await settle() + + const { entries, latestId } = log.since() + expect(latestId).toBe(2) + expect(entries.map(e => [e.method, e.path, e.status])).toEqual([ + ['GET', '/index.html', 200], + ['POST', '/missing', 404], + ]) + expect(entries[0].bytes).toBeGreaterThan('hello'.length) + expect(entries[0].durationMs).toBeGreaterThanOrEqual(0) + expect(entries[0].address).toContain('127.0.0.1') + }) + + it('skips the dashboard\'s own polling', async () => { + const { log, port } = await listen((_req, res) => res.end()) + + await fetch(`http://127.0.0.1:${port}/api/game/metrics`) + await fetch(`http://127.0.0.1:${port}/api/game/logs?after=3`) + await fetch(`http://127.0.0.1:${port}/api/game/requests`) + await fetch(`http://127.0.0.1:${port}/api/game/health`) + await settle() + + expect(log.since().entries.map(e => e.path)).toEqual(['/api/game/health']) + }) + + it('records WebSocket upgrades with the handshake status', async () => { + const { log, port } = await listen((_req, res) => res.end()) + server!.on('upgrade', (_req, socket) => { + socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n') + }) + + expect(await upgrade(port, '/hmr?token=abc')).toBe('101') + await settle() + + expect(log.since().entries).toEqual([expect.objectContaining({ method: 'WS', path: '/hmr', status: 101 })]) + }) + + it('closes upgrades nothing else handles, like Node does without listeners', async () => { + const { log, port } = await listen((_req, res) => res.end()) + + expect(await upgrade(port, '/ws')).toBeNull() + await settle() + + expect(log.since().entries).toEqual([expect.objectContaining({ method: 'WS', path: '/ws', status: null })]) + }) + + it('returns only entries after the given id and drops the oldest past capacity', () => { + const log = new RequestLog(2) + for (const path of ['/a', '/b', '/c']) + log.push({ timestamp: 0, method: 'GET', path, status: 200, durationMs: 1, bytes: 0, address: null }) + + expect(log.since().entries.map(e => e.id)).toEqual([2, 3]) + expect(log.since(2)).toEqual({ entries: [expect.objectContaining({ path: '/c' })], latestId: 3 }) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts new file mode 100644 index 0000000..28c1d9c --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/ServerMetrics.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' +import BaseGame from '../../../../src/BaseGame' +import Player from '../../../../src/Networking/Entities/Player' +import ServerMetrics, { METRICS_SCHEMA_VERSION, SERVER_BANDWIDTH_ID } from '../../../../src/Networking/Server/Stats/ServerMetrics' +import ServerWorld from '../../../../src/Networking/Server/World' + +class TestPlayer extends Player { + health = 100 + maxHealth = 100 + takeDamage(_amount: number): void { } + heal(_amount: number): void { } + update(_delta: number): void { } +} + +function makeMetrics(latencySimulation?: any) { + const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() } as any + const game = new BaseGame(null) + game.world = new ServerWorld() + + const metrics = new ServerMetrics({ game, latencySimulation }, logger, { sampleIntervalMs: 500 }) + + return { game, metrics } +} + +function addPlayer(game: BaseGame, id: string) { + const player = new TestPlayer(id) + game.world.entities.items.set(id, player) + return player +} + +describe('serverMetrics', () => { + it('builds a snapshot of the server', () => { + const { game, metrics } = makeMetrics({ fixedLatency: 50, jitter: 10, packetLoss: 0 }) + + const player = addPlayer(game, 'p1') + player.position.set(1, 2, 3) + player.updateLastProcessedSequenceId(7) + player.trackedEntities.add('p1') + player.setDisplayName('Alice') + + const snapshot = metrics.snapshot() + + expect(snapshot.schemaVersion).toBe(METRICS_SCHEMA_VERSION) + expect(snapshot.sampleIntervalMs).toBe(500) + expect(snapshot.tickRate).toBe(game.tickRate) + expect(snapshot.players).toEqual([{ + id: 'p1', + displayName: 'Alice', + position: { x: 1, y: 2, z: 3 }, + lastProcessedSequenceId: 7, + trackedEntities: 1, + connectedFor: 0, + connection: null, + }]) + expect(snapshot.world.entities).toBe(1) + expect(snapshot.physics).toBeNull() + expect(snapshot.latencySimulation).toEqual({ fixedLatency: 50, jitter: 10, packetLoss: 0 }) + expect(snapshot.memory.rss).toBeGreaterThan(0) + }) + + it('includes how long players have been connected and their connection details', () => { + const { game, metrics } = makeMetrics() + + const p1 = addPlayer(game, 'p1') + p1.connectedAt = Date.now() - 90_000 + p1.channel = { webrtcConnection: { peerConnection: { + rtt: () => 42, + bytesSent: () => 1000, + bytesReceived: () => 200, + getSelectedCandidatePair: () => ({ remote: { address: '203.0.113.7' } }), + } } } as any + addPlayer(game, 'bot') + + const [connected, bot] = metrics.snapshot().players + expect(connected.connectedFor).toBe(90) + expect(connected.connection).toEqual({ address: '203.0.113.7', rttMs: 42, bytesSent: 1000, bytesReceived: 200 }) + expect(bot.connection).toBeNull() + }) + + it('uses the player id as the display name until one is set', () => { + const { game, metrics } = makeMetrics() + + const player = addPlayer(game, 'p1') + expect(metrics.snapshot().players[0].displayName).toBe('p1') + + player.setDisplayName('Alice') + expect(metrics.snapshot().players[0].displayName).toBe('Alice') + + player.setDisplayName(null) + expect(metrics.snapshot().players[0].displayName).toBe('p1') + }) + + it('leaves out players that are marked for removal', () => { + const { game, metrics } = makeMetrics() + + addPlayer(game, 'p1') + addPlayer(game, 'p2').destroy() + + expect(metrics.snapshot().players.map(p => p.id)).toEqual(['p1']) + expect(metrics.health().players).toBe(1) + }) + + it('reports bandwidth per second from the latest sample', () => { + const { metrics } = makeMetrics() + + metrics.bandwidth.recordSent(SERVER_BANDWIDTH_ID, 100) + metrics.bandwidth.recordReceived(SERVER_BANDWIDTH_ID, 40) + metrics.sample() + + const { bandwidth } = metrics.snapshot() + + // sampled every 500ms so per second is double + expect(bandwidth.sentPerSecond).toBe(200) + expect(bandwidth.receivedPerSecond).toBe(80) + expect(bandwidth.history).toHaveLength(1) + }) + + it('samples every tracker', () => { + const { metrics } = makeMetrics() + + metrics.tick.record(5, 2) + metrics.sample() + + const snapshot = metrics.snapshot() + + expect(snapshot.cpu.history).toHaveLength(1) + expect(snapshot.tick.latest).toMatchObject({ ticks: 1, avgMs: 5, maxCommandsPerTick: 2 }) + expect(snapshot.tick.budgetMs).toBeCloseTo(1000 / 30, 1) + }) + + it('serializes to JSON without losing data', () => { + const { metrics } = makeMetrics() + metrics.sample() + + const snapshot = metrics.snapshot() + + expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot) + }) +}) diff --git a/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts b/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts new file mode 100644 index 0000000..ad7b574 --- /dev/null +++ b/packages/core/tests/Networking/Server/Stats/TickTracker.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import TickTracker from '../../../../src/Networking/Server/Stats/TickTracker' + +describe('tickTracker', () => { + it('summarises the ticks recorded since the last sample', () => { + const tracker = new TickTracker(60, 0) + + tracker.record(2, 1) + tracker.record(4, 3) + tracker.record(6, 0) + + const sample = tracker.sample(500) + + expect(sample).toEqual({ + timestamp: 500, + ticks: 3, + ticksPerSecond: 6, + avgMs: 4, + maxMs: 6, + commandsPerSecond: 8, + maxCommandsPerTick: 3, + }) + expect(tracker.latest).toBe(sample) + }) + + it('resets counters after sampling', () => { + const tracker = new TickTracker(60, 0) + + tracker.record(10, 5) + tracker.sample(1000) + + expect(tracker.sample(2000)).toMatchObject({ ticks: 0, avgMs: 0, maxMs: 0, commandsPerSecond: 0 }) + }) + + it('caps the history length', () => { + const tracker = new TickTracker(2, 0) + + tracker.sample(1000) + tracker.sample(2000) + tracker.sample(3000) + + expect(tracker.history.map(s => s.timestamp)).toEqual([2000, 3000]) + }) +}) diff --git a/packages/core/tests/Utils/BootBanner.test.ts b/packages/core/tests/Utils/BootBanner.test.ts new file mode 100644 index 0000000..bb52325 --- /dev/null +++ b/packages/core/tests/Utils/BootBanner.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { bootBanner } from '../../src/Utils/BootBanner' + +describe('bootBanner', () => { + it('lists the versions in a closed box', () => { + const lines = bootBanner({ version: '0.0.20', threeRevision: '180', rapierVersion: '0.19.0', mode: 'production' }) + + expect(lines).toContain('│ Version: 0.0.20 │') + expect(lines).toContain('│ Three.js: r180 │') + expect(lines).toContain('│ Rapier: 0.19.0 │') + expect(lines).toContain('│ Mode: production │') + expect(new Set(lines.map(line => line.length))).toEqual(new Set([lines[0].length])) + }) + + it('leaves out the versions it was not given', () => { + const lines = bootBanner({ version: '0.0.20', mode: 'development' }) + + expect(lines.some(line => line.includes('Three.js') || line.includes('Rapier'))).toBe(false) + }) +}) diff --git a/packages/multiplayer-template/README.md b/packages/multiplayer-template/README.md index 35a6ee0..f07079a 100644 --- a/packages/multiplayer-template/README.md +++ b/packages/multiplayer-template/README.md @@ -3,7 +3,7 @@ Bootstrap this template instantly with: ```bash -npx @mavonengine/create-bootstrap +npx @mavonengine/cli create my-game ``` A ready-to-use multiplayer 3D game template built on the [MavonEngine](https://mavonengine.com/) framework. It demonstrates a client-server architecture with authoritative physics, real-time state synchronisation, player animations, chat, and a 3D world with environmental objects. @@ -123,7 +123,7 @@ States are layered: the server defines the logic, the client extends each state To view the scene from the server's perspective (what the authoritative physics world sees), navigate to: ``` -http://localhost:5173/#debug +http://localhost:8050/#debug ``` This enables the debug overlay and renders the server-side physics colliders and entity positions directly in the browser. @@ -136,10 +136,10 @@ From the workspace root: ```bash npm install -npm run dev # starts both client and server in watch mode +npm run dev # starts the game server in watch mode, which serves the client through Vite ``` -The client runs at `http://localhost:5173` and the server listens on port `8081`. +Open `http://localhost:8050`. The game server serves the client on its HTTP port, with hot reload for client changes. Server changes rebuild and restart the server, which reloads the page. The client connects to the game socket through `/socket` on the same port, so everything runs on `8050`. The game server exposes a JSON API with a health endpoint: @@ -147,6 +147,15 @@ The game server exposes a JSON API with a health endpoint: GET http://localhost:8050/api/game/health ``` +### Production + +```bash +npm run build +npm start --prefix server +``` + +The server serves the built client from `client/dist` on port `8050`, and the game socket is reachable on the same port, so a single process is enough. Vite is never loaded outside `npm run dev`. Behind a reverse proxy or CDN you can still serve `client/dist` yourself and forward `/socket` to the game server's port `8050`. + --- ## Credits diff --git a/packages/multiplayer-template/client/index.html b/packages/multiplayer-template/client/index.html index 24a0552..c1fd80d 100644 --- a/packages/multiplayer-template/client/index.html +++ b/packages/multiplayer-template/client/index.html @@ -5,6 +5,7 @@ Multiplayer Template + diff --git a/packages/multiplayer-template/client/public/favicon.ico b/packages/multiplayer-template/client/public/favicon.ico new file mode 100644 index 0000000..45fc6e9 Binary files /dev/null and b/packages/multiplayer-template/client/public/favicon.ico differ diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index c8ccc17..e0fac4d 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -13,9 +13,10 @@ export default class NetworkManager extends BaseNetworkManager { constructor() { super({ - /** @ts-expect-error port is null in prod when behind a reverse proxy */ - port: import.meta.env.PROD ? null : 8081, - url: import.meta.env.PROD ? '/socket' : undefined, + // Same origin as the page: the game server answers /socket on its HTTP port, and so does a reverse proxy in front of it + /** @ts-expect-error port is null to use the page's own port */ + port: null, + url: '/socket', }) } diff --git a/packages/multiplayer-template/package-lock.json b/packages/multiplayer-template/package-lock.json index e19cb57..7a067f6 100644 --- a/packages/multiplayer-template/package-lock.json +++ b/packages/multiplayer-template/package-lock.json @@ -19,7 +19,6 @@ "@antfu/eslint-config": "^8.0.0", "@mavonengine/editor": "0.0.8", "@types/three": "0.184.1", - "concurrently": "^9.1.2", "eslint": "^10.1.0", "tsup": "^8.5.1", "typescript": "5.9.2" @@ -1261,9 +1260,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1277,9 +1273,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1293,9 +1286,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1309,9 +1299,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1325,9 +1312,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1341,9 +1325,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1357,9 +1338,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1373,9 +1351,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1389,9 +1364,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1405,9 +1377,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1421,9 +1390,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1437,9 +1403,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1453,9 +1416,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2309,32 +2269,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/ansis": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz", @@ -2642,36 +2576,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -2751,21 +2655,6 @@ "node": ">=0.8.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2789,26 +2678,6 @@ "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/color-string": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", @@ -2871,31 +2740,6 @@ "node": ">= 12.0.0" } }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -3160,13 +3004,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/empathic": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", @@ -4452,16 +4289,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4577,16 +4404,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4764,16 +4581,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6895,16 +6702,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7013,16 +6810,6 @@ "node": ">= 18" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7166,19 +6953,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7374,34 +7148,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -7457,22 +7203,6 @@ "node": ">= 6" } }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -8157,6 +7887,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -8173,6 +7904,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8189,6 +7921,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8205,6 +7938,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -8221,6 +7955,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8237,6 +7972,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8253,6 +7989,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8269,6 +8006,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8285,6 +8023,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8301,6 +8040,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8317,6 +8057,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8333,6 +8074,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8349,6 +8091,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8365,6 +8108,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8381,6 +8125,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8397,6 +8142,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8413,6 +8159,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8429,6 +8176,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8445,6 +8193,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8461,6 +8210,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8477,6 +8227,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8493,6 +8244,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -8509,6 +8261,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -8525,6 +8278,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8541,6 +8295,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8557,6 +8312,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8722,24 +8478,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8756,16 +8494,6 @@ "node": ">=12" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -8812,35 +8540,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8878,7 +8577,8 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "vite": "^6.3.5" } } } diff --git a/packages/multiplayer-template/package.json b/packages/multiplayer-template/package.json index e150b68..beb2cbf 100644 --- a/packages/multiplayer-template/package.json +++ b/packages/multiplayer-template/package.json @@ -7,20 +7,19 @@ ], "license": "MIT", "scripts": { - "dev": "concurrently --kill-others -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"", - "build": "concurrently \"npm run build --prefix server\" \"npm run build --prefix client\"", + "dev": "npm run dev --prefix server", + "build": "npm run build --workspaces", "typecheck": "npm run typecheck --prefix server && npm run typecheck --prefix client", "lint": "eslint .", "lint:fix": "eslint . --fix" }, "dependencies": { - "@mavonengine/core": "0.0.20" + "@mavonengine/core": "0.0.21" }, "devDependencies": { "@antfu/eslint-config": "^8.0.0", "@mavonengine/editor": "0.0.8", "@types/three": "0.184.1", - "concurrently": "^9.1.2", "eslint": "^10.1.0", "tsup": "^8.5.1", "typescript": "5.9.2" diff --git a/packages/multiplayer-template/server/package.json b/packages/multiplayer-template/server/package.json index 1090f55..f14c710 100644 --- a/packages/multiplayer-template/server/package.json +++ b/packages/multiplayer-template/server/package.json @@ -6,7 +6,7 @@ "main": "src/index.ts", "scripts": { "start": "cross-env LOG_LEVEL=debug node ./dist/index.js", - "dev": "tsup --watch --onSuccess \"npm run start\"", + "dev": "tsup --watch --onSuccess \"cross-env MAVONENGINE_DEV_CLIENT=1 npm run start\"", "build": "tsup", "typecheck": "tsc --noEmit" }, @@ -20,6 +20,7 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "vite": "^6.3.5" } } diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index 24342a3..39a1b67 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -1,5 +1,6 @@ import type RAPIER from '@dimforge/rapier3d-compat' import type { ServerChannel } from '@geckos.io/server' +import type { ServerOptions } from '@mavonengine/core/Networking/Server/Server' import type winston from 'winston' import type { CL_CHAT, CL_INIT, CL_MOVE } from './Commands/Client' import type { SV_TREES } from './Commands/Server' @@ -20,8 +21,8 @@ export default class Server extends BaseServer { private treeScales: number[] = [] private trees: Tree[] = [] - constructor(logger: winston.Logger, physicsWorld?: RAPIER.World) { - super(logger, physicsWorld) + constructor(logger: winston.Logger, physicsWorld?: RAPIER.World, options?: ServerOptions) { + super(logger, physicsWorld, options) this.spawnTrees() } @@ -61,6 +62,7 @@ export default class Server extends BaseServer { channel.on(ClientCommand.CL_INIT, (command) => { const cmd = command as CL_INIT player.name = cmd.name + player.setDisplayName(cmd.name) this.gameSocket.emit(ServerCommand.SV_CHAT, { playerId: '', diff --git a/packages/multiplayer-template/server/src/index.ts b/packages/multiplayer-template/server/src/index.ts index c840108..b8dedd3 100644 --- a/packages/multiplayer-template/server/src/index.ts +++ b/packages/multiplayer-template/server/src/index.ts @@ -1,3 +1,5 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' import RAPIER from '@dimforge/rapier3d-compat' import logger from './Logger' import Server from './Server' @@ -11,6 +13,12 @@ RAPIER.init().then(() => { groundBody, ) - const server = new Server(logger, physicsWorld) + const server = new Server(logger, physicsWorld, { + client: { + root: fileURLToPath(new URL('../../client', import.meta.url)), + // Set by `npm run dev`: serve the client source through Vite. Otherwise the built client/dist is served + dev: process.env.MAVONENGINE_DEV_CLIENT === '1', + }, + }) server.start() }) diff --git a/packages/multiplayer-template/server/tsup.config.ts b/packages/multiplayer-template/server/tsup.config.ts index 0a4e331..623e196 100644 --- a/packages/multiplayer-template/server/tsup.config.ts +++ b/packages/multiplayer-template/server/tsup.config.ts @@ -14,5 +14,7 @@ export default defineConfig({ 'express', 'winston', 'three', + // Only loaded in dev to serve the client, never bundled + 'vite', ], })