diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c1020cb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +indent_style = tab +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +indent_style = space +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f0ef0f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=crlf +bun.lock text eol=lf diff --git a/.gitignore b/.gitignore index 341de79..8d4d731 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ .vscode dist node_modules -bun.lock \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..f071f3c --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "useTabs": true, + "tabWidth": 2, + "printWidth": 80, + "endOfLine": "crlf", + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "arrowParens": "avoid", + "bracketSpacing": true, + "sortImports": true, + "ignorePatterns": ["**/dist", "bun.lock"], + "overrides": [ + { + "files": ["*.md"], + "options": { "embeddedLanguageFormatting": "off" } + } + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6650c0c --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,22 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "import", "jsdoc"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + "typescript/no-explicit-any": "error", + "typescript/no-unsafe-function-type": "error", + "eslint/no-underscore-dangle": "off", + "jsdoc/check-tag-names": [ + "error", + { "typed": true, "definedTags": ["defaultValue", "remarks", "typeParam"] } + ], + "jsdoc/empty-tags": "error", + "jsdoc/no-blank-blocks": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-name": "error" + }, + "ignorePatterns": ["**/dist"] +} diff --git a/biome.json b/biome.json deleted file mode 100644 index b0a61b1..0000000 --- a/biome.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "includes": ["**", "!dist/*"] - }, - "formatter": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noBannedTypes": "info" - }, - "suspicious": { - "noExplicitAny": "info" - } - } - }, - "javascript": { - "formatter": { - "arrowParentheses": "asNeeded", - "bracketSpacing": true, - "indentWidth": 2, - "lineEnding": "crlf", - "lineWidth": 80, - "quoteStyle": "single", - "semicolons": "asNeeded", - "trailingCommas": "all" - } - }, - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } - } -} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1f64568 --- /dev/null +++ b/bun.lock @@ -0,0 +1,302 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "devDependencies": { + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", + "typescript": "^7.0.2", + }, + }, + "rpc": { + "name": "@entityseven/fivem-rpc", + "version": "1.0.0", + "dependencies": { + "@entityseven/fivem-rpc-shared-types": "workspace:^", + }, + "devDependencies": { + "tsdown": "^0.23.0", + }, + "peerDependencies": { + "typescript": ">=5", + }, + "optionalPeers": [ + "typescript", + ], + }, + "shared-types": { + "name": "@entityseven/fivem-rpc-shared-types", + "version": "1.0.0", + "peerDependencies": { + "typescript": ">=5", + }, + "optionalPeers": [ + "typescript", + ], + }, + }, + "packages": { + "@entityseven/fivem-rpc": ["@entityseven/fivem-rpc@workspace:rpc"], + + "@entityseven/fivem-rpc-shared-types": ["@entityseven/fivem-rpc-shared-types@workspace:shared-types"], + + "@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.85.0", "", { "os": "android", "cpu": "arm" }, "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.85.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.85.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.85.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.85.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.85.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.85.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.85.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.85.0", "", { "os": "linux", "cpu": "none" }, "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.85.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.85.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.85.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.85.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.85.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="], + + "@quansync/fs": ["@quansync/fs@1.1.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@yuku-codegen/binding-android-arm64": ["@yuku-codegen/binding-android-arm64@0.10.2", "", { "os": "android", "cpu": "arm64" }, "sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g=="], + + "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A=="], + + "@yuku-codegen/binding-darwin-x64": ["@yuku-codegen/binding-darwin-x64@0.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA=="], + + "@yuku-codegen/binding-freebsd-x64": ["@yuku-codegen/binding-freebsd-x64@0.10.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w=="], + + "@yuku-codegen/binding-linux-arm-gnu": ["@yuku-codegen/binding-linux-arm-gnu@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A=="], + + "@yuku-codegen/binding-linux-arm-musl": ["@yuku-codegen/binding-linux-arm-musl@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q=="], + + "@yuku-codegen/binding-linux-arm64-gnu": ["@yuku-codegen/binding-linux-arm64-gnu@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw=="], + + "@yuku-codegen/binding-linux-arm64-musl": ["@yuku-codegen/binding-linux-arm64-musl@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA=="], + + "@yuku-codegen/binding-linux-x64-gnu": ["@yuku-codegen/binding-linux-x64-gnu@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg=="], + + "@yuku-codegen/binding-linux-x64-musl": ["@yuku-codegen/binding-linux-x64-musl@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q=="], + + "@yuku-codegen/binding-win32-arm64": ["@yuku-codegen/binding-win32-arm64@0.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw=="], + + "@yuku-codegen/binding-win32-x64": ["@yuku-codegen/binding-win32-x64@0.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA=="], + + "@yuku-parser/binding-android-arm64": ["@yuku-parser/binding-android-arm64@0.10.2", "", { "os": "android", "cpu": "arm64" }, "sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw=="], + + "@yuku-parser/binding-darwin-arm64": ["@yuku-parser/binding-darwin-arm64@0.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA=="], + + "@yuku-parser/binding-darwin-x64": ["@yuku-parser/binding-darwin-x64@0.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ=="], + + "@yuku-parser/binding-freebsd-x64": ["@yuku-parser/binding-freebsd-x64@0.10.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ=="], + + "@yuku-parser/binding-linux-arm-gnu": ["@yuku-parser/binding-linux-arm-gnu@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA=="], + + "@yuku-parser/binding-linux-arm-musl": ["@yuku-parser/binding-linux-arm-musl@0.10.2", "", { "os": "linux", "cpu": "arm" }, "sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg=="], + + "@yuku-parser/binding-linux-arm64-gnu": ["@yuku-parser/binding-linux-arm64-gnu@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ=="], + + "@yuku-parser/binding-linux-arm64-musl": ["@yuku-parser/binding-linux-arm64-musl@0.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A=="], + + "@yuku-parser/binding-linux-x64-gnu": ["@yuku-parser/binding-linux-x64-gnu@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg=="], + + "@yuku-parser/binding-linux-x64-musl": ["@yuku-parser/binding-linux-x64-musl@0.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w=="], + + "@yuku-parser/binding-win32-arm64": ["@yuku-parser/binding-win32-arm64@0.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw=="], + + "@yuku-parser/binding-win32-x64": ["@yuku-parser/binding-win32-x64@0.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.10.2", "", {}, "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q=="], + + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + + "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], + + "hookable": ["hookable@6.1.2", "", {}, "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg=="], + + "import-without-cache": ["import-without-cache@0.4.1", "", {}, "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "oxfmt": ["oxfmt@0.70.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.70.0", "@oxfmt/binding-android-arm64": "0.70.0", "@oxfmt/binding-darwin-arm64": "0.70.0", "@oxfmt/binding-darwin-x64": "0.70.0", "@oxfmt/binding-freebsd-x64": "0.70.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.70.0", "@oxfmt/binding-linux-arm-musleabihf": "0.70.0", "@oxfmt/binding-linux-arm64-gnu": "0.70.0", "@oxfmt/binding-linux-arm64-musl": "0.70.0", "@oxfmt/binding-linux-ppc64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-gnu": "0.70.0", "@oxfmt/binding-linux-riscv64-musl": "0.70.0", "@oxfmt/binding-linux-s390x-gnu": "0.70.0", "@oxfmt/binding-linux-x64-gnu": "0.70.0", "@oxfmt/binding-linux-x64-musl": "0.70.0", "@oxfmt/binding-openharmony-arm64": "0.70.0", "@oxfmt/binding-win32-arm64-msvc": "0.70.0", "@oxfmt/binding-win32-ia32-msvc": "0.70.0", "@oxfmt/binding-win32-x64-msvc": "0.70.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw=="], + + "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.28.6", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.6", "obug": "^3.0.0", "yuku-ast": "^0.10.1", "yuku-codegen": "^0.10.1", "yuku-parser": "^0.10.1" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "@vue/language-core": "~3.2.0 || ~3.3.0", "rolldown": "^1.2.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "@vue/language-core", "typescript", "vue-tsc"] }, "sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@2.1.2", "", {}, "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww=="], + + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tsdown": ["tsdown@0.23.0", "", { "dependencies": { "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.7", "rolldown": "~1.2.7", "rolldown-plugin-dts": "^0.28.5", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.4.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.23.0", "@tsdown/exe": "0.23.0", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": ">=0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + + "verkit": ["verkit@0.4.1", "", {}, "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ=="], + + "yuku-ast": ["yuku-ast@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2" } }, "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ=="], + + "yuku-codegen": ["yuku-codegen@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.10.2", "@yuku-codegen/binding-darwin-arm64": "0.10.2", "@yuku-codegen/binding-darwin-x64": "0.10.2", "@yuku-codegen/binding-freebsd-x64": "0.10.2", "@yuku-codegen/binding-linux-arm-gnu": "0.10.2", "@yuku-codegen/binding-linux-arm-musl": "0.10.2", "@yuku-codegen/binding-linux-arm64-gnu": "0.10.2", "@yuku-codegen/binding-linux-arm64-musl": "0.10.2", "@yuku-codegen/binding-linux-x64-gnu": "0.10.2", "@yuku-codegen/binding-linux-x64-musl": "0.10.2", "@yuku-codegen/binding-win32-arm64": "0.10.2", "@yuku-codegen/binding-win32-x64": "0.10.2" } }, "sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA=="], + + "yuku-parser": ["yuku-parser@0.10.2", "", { "dependencies": { "@yuku-toolchain/types": "^0.10.2", "yuku-ast": "^0.10.2" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.10.2", "@yuku-parser/binding-darwin-arm64": "0.10.2", "@yuku-parser/binding-darwin-x64": "0.10.2", "@yuku-parser/binding-freebsd-x64": "0.10.2", "@yuku-parser/binding-linux-arm-gnu": "0.10.2", "@yuku-parser/binding-linux-arm-musl": "0.10.2", "@yuku-parser/binding-linux-arm64-gnu": "0.10.2", "@yuku-parser/binding-linux-arm64-musl": "0.10.2", "@yuku-parser/binding-linux-x64-gnu": "0.10.2", "@yuku-parser/binding-linux-x64-musl": "0.10.2", "@yuku-parser/binding-win32-arm64": "0.10.2", "@yuku-parser/binding-win32-x64": "0.10.2" } }, "sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A=="], + + "rolldown-plugin-dts/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], + } +} diff --git a/migration.md b/migration.md new file mode 100644 index 0000000..19c2ec8 --- /dev/null +++ b/migration.md @@ -0,0 +1,53 @@ +# Migrating from 0.1 to 1.0 + +Upgrade server, client and webview together. The payload format changed, so 0.1 and 1.0 cannot talk to each other + +## Creating the instance + +`RPCFactory` is gone, use `createRPC`: + +```ts +// 0.1 +import { RPCFactory } from '@entityseven/fivem-rpc' +export const rpc = new RPCFactory({ env: 'server' }).get() + +// 1.0 +import { createRPC } from '@entityseven/fivem-rpc' +export const rpc = createRPC({ env: 'server' }) +``` + +## Errors and timeouts + +- a failed call now rejects on the caller with `RPCError` (`code`, `message`, `details`). In 0.1 the error was thrown on the receiving side and the call never settled +- every call times out after 5000 ms by default. Change it with `RPCConfig.timeout`, `0` restores the 0.1 behaviour (wait forever) +- `RPCErrors.INVALID_DATA` and `RPCErrors.NO_PLAYER` are removed (invalid payloads are dropped), `RPCErrors.TIMEOUT` and `RPCErrors.HANDLER_ERROR` are new +- the texts of `RPCErrors.UNKNOWN_NATIVE` and `RPCErrors.UNKNOWN_ENVIRONMENT` changed. Compare `error.code` with `RPCErrors`, not message strings + +See [Errors](rpc/readme.md#errors) and [How it works](rpc/readme.md#how-it-works) + +## Typing + +Declarations are now module augmentation of interfaces, the `types` / `typeRoots` tsconfig setup is no longer needed. Follow the [shared-types readme](shared-types/readme.md), then: + +- commands are interface keys instead of string unions: + + ```ts + // 0.1 + export type RPCCommands_Server = 'ban' | 'kick' + + // 1.0 + interface RPCCommands_Server { + ban: true + kick: true + } + ``` + +- remove placeholder members such as `_(): void`. An interface with any member is strict, an empty one accepts everything + +## Other API changes + +- `onCommand` callbacks get `args` as `string[]` (the generic argument type is gone) +- `RPCNativeClientNetworksEvents` is renamed to `RPCNativeClientNetworkEvents` +- internal types are no longer exported: `RPCEnvironmentResolved`, `RPCEventType`, `RPCEvents`, `RPCState`, `RPCStateRaw`, `RPCStateWeb`, `RPCStateWebRaw`. Use `RPCInstanceServer`, `RPCInstanceClient` or `RPCInstanceWebview` for the instance type +- server listeners get the player id from FiveM's `source`. Ids a client puts into the payload are ignored +- `emitClientEveryone` is one-way: clients run their listener but no longer send a response diff --git a/package.json b/package.json index a88323f..f169a9d 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,6 @@ { - "workspaces": [ - "rpc", - "shared-types" - ], - "devDependencies": { - "@biomejs/biome": "^2.1.1", - "@types/bun": "^1.2.18" - }, - "peerDependencies": { - "typescript": "^5" - }, - "scripts": { - "check": "bunx biome check", - "format": "bunx biome format --write" - }, "private": true, - "type": "module", - "license": "CC0-1.0", + "license": "SEE LICENSE IN license.md", "author": "Entity Seven Group", "contributors": [ { @@ -33,5 +17,23 @@ "repository": { "type": "git", "url": "https://github.com/rilaxik/fivem-rpc.git" + }, + "workspaces": [ + "rpc", + "shared-types" + ], + "type": "module", + "scripts": { + "build": "bun run --filter '*' build", + "check": "oxfmt --check && oxlint", + "format": "oxfmt", + "format:check": "oxfmt --check", + "lint": "oxlint", + "typecheck": "bun run --filter '*' typecheck" + }, + "devDependencies": { + "oxfmt": "^0.70.0", + "oxlint": "^1.85.0", + "typescript": "^7.0.2" } } diff --git a/readme.md b/readme.md index 223ef90..1996a72 100644 --- a/readme.md +++ b/readme.md @@ -1,41 +1,87 @@ # FiveM RPC -is an all-in-one package with asynchronous RPC implementation for FiveM servers in JS/TS + +Call FiveM server, client and NUI listeners like async functions: typed, with timeouts, no event ping-pong + +## Motivation + +The idea was to create an extensible package, with various features to simplify the development process and provide as much comfort as possible. Inspired by usage of [altv-xrpc](https://github.com/xxshady/altv-xrpc) + +## Packages + +| Package | Docs | +| ----------------------------------------------------- | -------------------------------------- | +| [`@entityseven/fivem-rpc`](rpc) | [API reference](rpc/readme.md) | +| [`@entityseven/fivem-rpc-shared-types`](shared-types) | [Typing setup](shared-types/readme.md) | ## Installation + ```bash - pnpm i @entityseven/fivem-rpc -``` -```bash - yarn add @entityseven/fivem-rpc -``` -```bash - bun add @entityseven/fivem-rpc -``` -It is highly recommended to also install additional package for enhanced typing -```bash - pnpm i @entityseven/fivem-rpc-shared-types -D -``` -```bash - yarn add @entityseven/fivem-rpc-shared-types --dev -``` -```bash - bun add @entityseven/fivem-rpc-shared-types -d +npm i @entityseven/fivem-rpc +pnpm add @entityseven/fivem-rpc +yarn add @entityseven/fivem-rpc +bun add @entityseven/fivem-rpc ``` -## Docs -Can be found in [/rpc/readme.md](https://github.com/rilaxik/fivem-rpc/blob/master/rpc/readme.md) +Optional, for typed event names, arguments and results ([typing setup](shared-types/readme.md)): + +```bash +npm i -D @entityseven/fivem-rpc-shared-types +pnpm add -D @entityseven/fivem-rpc-shared-types +yarn add -D @entityseven/fivem-rpc-shared-types +bun add -d @entityseven/fivem-rpc-shared-types +``` + +Upgrading from 0.1: [migration guide](migration.md) + +## Quick start + +Create exactly one instance per environment and import it from your own module, not from the library. The client needs one even if it only relays between server and webview. + +```ts +// server/rpc.ts +import { createRPC } from '@entityseven/fivem-rpc' +export const rpc = createRPC({ env: 'server' }) + +// client/rpc.ts +import { createRPC } from '@entityseven/fivem-rpc' +export const rpc = createRPC({ env: 'client' }) + +// webview/rpc.ts +import { createRPC } from '@entityseven/fivem-rpc' +export const rpc = createRPC({ env: 'webview' }) +``` + +Listen on one side, emit from the other and await the listener's return value: + +```ts +// server +rpc.onClient('ping', (player, message) => `pong: ${message} (from ${player})`) + +// client +const reply = await rpc.emitServer('ping', 'hello') +``` + +All methods: [API reference](rpc/readme.md). ## Features + - Type-Safe Development: Eliminate runtime errors and enhance code reliability with comprehensive type safety - All-in-one package: Communicate effortlessly between server, client and webview ## Contributing + Issues and pull requests are very welcome +When the API changes, update the TSDoc, the [direction table](rpc/readme.md#directions) and the [agent skill](rpc/skills/fivem-rpc/SKILL.md) + +Releases are published with `bun publish`, which replaces the `workspace:^` dependency on shared-types with its version (`npm publish` would not) + ## License -Licensed under Custom Attribution-NoDerivs Software License + +Licensed under the [Custom Attribution-NoDerivs Software License](license.md) + +## Roadmap -## WIP - client observers to catch events between server and webview (subscribe-like behaviour) - client observers to prevent events (middleware-like behaviour) -- player manager (transform player id to desired data straight from a listener) \ No newline at end of file +- player manager (transform player id to desired data straight from a listener) diff --git a/rpc/package.json b/rpc/package.json index 46ca8d6..ebe996b 100644 --- a/rpc/package.json +++ b/rpc/package.json @@ -1,43 +1,69 @@ { "name": "@entityseven/fivem-rpc", - "description": "FiveM RPC is an all-in-one package with asynchronous RPC implementation for FiveM servers in JS/TS", - "version": "0.1.0", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist/**/*", - "readme.md", - "license.md" - ], + "version": "1.0.0", + "description": "Call FiveM server, client and NUI listeners like async functions: typed, with timeouts, no event ping-pong", "keywords": [ - "fivem-rpc", - "fivem-rpc-shared-types", + "cfx", "fivem", - "gta" + "fivem-rpc", + "gta", + "nui", + "rpc", + "typescript" ], + "license": "SEE LICENSE IN license.md", "author": "Entity Seven Group", "contributors": [ { "name": "Danya H", "email": "dev.rilaxik@gmail.com", "url": "https://github.com/rilaxik/" + }, + { + "name": "Oleksandr Honcharov", + "email": "0976053529@ukr.net", + "url": "https://github.com/SashaGoncharov19/" } ], - "license": "Custom-Attribution-NoDerivs", "repository": { "type": "git", - "url": "https://github.com/rilaxik/fivem-rpc.git" + "url": "https://github.com/rilaxik/fivem-rpc.git", + "directory": "rpc" + }, + "files": [ + "dist/**/*", + "skills/**/*", + "readme.md", + "license.md" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" }, "scripts": { - "build": "tsup" + "build": "tsdown", + "prepublishOnly": "bun run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@entityseven/fivem-rpc-shared-types": "workspace:^" }, "devDependencies": { - "@microsoft/api-extractor": "^7.47.9", - "@citizenfx/client": "^2.0.15015-1", - "@citizenfx/server": "^2.0.14862-1", - "tsup": "^8.3.0" + "tsdown": "^0.23.0" }, "peerDependencies": { - "typescript": "^5" + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } } diff --git a/rpc/readme.md b/rpc/readme.md index 577e648..8e4ee18 100644 --- a/rpc/readme.md +++ b/rpc/readme.md @@ -1,300 +1,436 @@ # FiveM RPC -is an all-in package with asynchronous RPC implementation for RageMP servers in JS/TS. [Extra info](https://github.com/rilaxik/fivem-rpc/blob/master/readme.md) -# Motivation -The idea was to create an extensible package, with various features to simplify the development process and provide as much comfort as possible. Inspired by usage of [altv-xrpc](https://github.com/xxshady/altv-xrpc) +Call FiveM server, client and NUI listeners like async functions: typed, with timeouts, no event ping-pong -# Installation -```bash - pnpm i @entityseven/fivem-rpc -``` -```bash - yarn add @entityseven/fivem-rpc -``` -```bash - bun add @entityseven/fivem-rpc -``` -It is highly recommended to also install additional package for enhanced typing -```bash - pnpm i @entityseven/fivem-rpc-shared-types -D -``` -```bash - yarn add @entityseven/fivem-rpc-shared-types --dev -``` -```bash - bun add @entityseven/fivem-rpc-shared-types -d -``` +Installation, quick start and package overview: [main readme](../readme.md). Typed events: [shared-types](../shared-types/readme.md). Upgrading from 0.1: [migration guide](../migration.md). -## Usage -FiveM RPC is meant to be a singletone per environment. This means you _must create only one_ `RPCFactory` per your server/client/web. This also enables modifying `const rpc` to your needs, adding new methods or variables by forcing you to import it from file instead of library reference -```ts -// lib/rpc.ts -import { RPCFactory } from '@entityseven/fivem-rpc' -export const rpc = new RPCFactory(/* options */).get() -``` +## Exports -# Docs +Besides `createRPC` the package exports: -## Extras -Along with `RPCFactory` you can also import all the types used internally, types for native client/server events and lists of native client/server events. All of that is documented in JSDoc, so no need to duplicate it here +- `RPCError`, `RPCErrors`, `RPCErrorDetails` - see [Errors](#errors) +- `RPCConfig`, `RPCEnvironment` and the instance types `RPCInstanceServer`, `RPCInstanceClient`, `RPCInstanceWebview` +- native event types `RPCNativeServerEvents`, `RPCNativeClientEvents`, `RPCNativeClientNetworkEvents`, `RPCNativeClientNetworkEventsNames` and the lists `NATIVE_SERVER_EVENTS`, `NATIVE_CLIENT_EVENTS`, `NATIVE_CLIENT_NETWORK_EVENTS` accepted by the `onNative*` methods ## RPCConfig + ```ts -type RPCConfig = { - env: T - debug?: boolean +type RPCConfig = { + env: 'server' | 'client' | 'webview' + debug?: boolean // default false, logs every registration, call and incoming payload + timeout?: number // default 5000, ms to wait for a response, 0 disables it } ``` -Failing to set `env` to provided type will result in `RPCErrors.UNKNOWN_ENVIRONMENT` -`debug` adds additional console logs to events +An unknown `env` makes `createRPC` throw `RPCError` with code `RPCErrors.UNKNOWN_ENVIRONMENT` ## Errors -Known errors could be one of following or an error throw by a callback specifically + +Every error from this library is an `RPCError`. `code` is one of `RPCErrors`, `message` says what to fix, and `details` names the call when the error comes from one + ```ts enum RPCErrors { EVENT_NOT_REGISTERED = 'Event not registered', - INVALID_DATA = 'Invalid data (possibly broken JSON)', - NO_PLAYER = 'No player (failed to resolve from local index)', - UNKNOWN_NATIVE = 'Unknown native event (if you are sure this exists - use native handler)', - UNKNOWN_ENVIRONMENT = 'Unknown environment (must be either "server", "client" or "webview")', + UNKNOWN_NATIVE = 'Unknown native event', + UNKNOWN_ENVIRONMENT = 'Unknown environment', + TIMEOUT = 'Timed out waiting for response', + HANDLER_ERROR = 'Listener threw an error', } ``` ### Example error -Values wrapped in `<>` always exist, just not relevant for an example. Keep in mind that some errors are thrown in their destination(`To`) point: this example will throw on server -``` -Error: No player (failed to resolve from local index) -Event: 'clientServerEvent' -Uuid: -From: 'client' -To: 'server' -Player: -Type: 'event' -Data: [] + +The server has no `onClient('buyItem', ...)` listener, so the call from the client rejects: + +```ts +import { RPCError, RPCErrors } from '@entityseven/fivem-rpc' + +try { + await rpc.emitServer('buyItem', 'water') +} catch (e) { + if (e instanceof RPCError && e.code === RPCErrors.EVENT_NOT_REGISTERED) { + e.message // 'No listener for "buyItem" on server. Register it with rpc.onClient("buyItem", ...) in server code.' + e.details // { event: 'buyItem', uuid: '', from: 'client', to: 'server' } + } +} ``` -## Server ([source](https://github.com/rilaxik/fivem-rpc/blob/master/rpc/src/core/server.ts)) +## How it works + +### Directions + +Every call goes from an `emit*` method in one environment to the matching `on*` listener in another. The last column is the [shared-types](../shared-types/readme.md) interface that types it + +| From | Call | To | Listener | Typed by | +| ------- | ------------------------------------- | ----------- | ------------------------------------- | ------------------------- | +| server | `emitClient(player, event, ...args)` | client | `onServer` | `RPCEvents_ServerClient` | +| server | `emitClientEveryone(event, ...args)` | all clients | `onServer`, no response | `RPCEvents_ServerClient` | +| server | `emitWebview(player, event, ...args)` | webview | `onServer`, via client | `RPCEvents_ServerWebview` | +| server | `emitSelf(event, ...args)` | server | `onSelf` | `RPCEvents_Server` | +| client | `emitServer(event, ...args)` | server | `onClient`, player first | `RPCEvents_ClientServer` | +| client | `emitWebview(event, ...args)` | webview | `onClient` | `RPCEvents_ClientWebview` | +| client | `emitSelf(event, ...args)` | client | `onSelf` | `RPCEvents_Client` | +| webview | `emitServer(event, ...args)` | server | `onWebview`, player first, via client | `RPCEvents_WebviewServer` | +| webview | `emitClient(event, ...args)` | client | `onWebview` | `RPCEvents_WebviewClient` | +| webview | `emitSelf(event, ...args)` | webview | `onSelf` | `RPCEvents_Webview` | + +Commands registered with `onCommand` are typed by `RPCCommands_Server` and `RPCCommands_Client` + +### Routing + +Server and client talk over FiveM network events, client and webview over NUI messages and NUI callbacks. Webview and server never talk directly: every call between them is relayed by the client of that player. So every client must run `createRPC({ env: 'client' })`, even with no listeners of its own, or those calls time out + +### One listener per event + +Each `on*` method keeps one listener per event name. Registering the same name again replaces the previous listener, `off*` removes it. Directions are separate: `onClient('x')` and `onWebview('x')` on the server do not replace each other + +### Responses, errors and timeouts + +- `emit*` resolves with the value the listener returns (promises are awaited) +- no listener on the target: the call rejects with `RPCErrors.EVENT_NOT_REGISTERED` +- the listener throws: the target logs the error with `console.error`, the call rejects with `RPCErrors.HANDLER_ERROR` and the original message +- no response within `RPCConfig.timeout` (default 5000 ms): the call rejects with `RPCErrors.TIMEOUT` and a late response is ignored. `timeout: 0` waits forever +- `emitSelf` calls the local listener directly, whatever it throws reaches the caller unchanged +- `emitClientEveryone` does not wait for clients: it resolves once sent, failures stay on each client (`console.error` for a throwing listener, the rest with `debug: true`) + +### Player identity + +Server listeners (`onClient`, `onWebview`) get the calling player's server id as the first argument. It comes from FiveM's `source`, never from the payload, so a client cannot pose as another player. Use it instead of player ids passed as arguments. A response to `emitClient` or `emitWebview` is only accepted from the player it was sent to + +## Server ([source](src/core/server.ts)) + ### onClient + Listens to client event + ```ts rpc.onClient('clientServerEvent', (player, arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offClient + Stops listening to client event + ```ts rpc.offClient('clientServerEvent') ``` + ### emitClient + Sends event to specified client + ```ts -const response = await rpc.emitClient(playerServerId, 'serverClientEvent', someData) +const response = await rpc.emitClient(playerServerId, 'serverClientEvent', someData) // response will come from client listener with returned data ``` + ### emitClientEveryone -Sends event to all clients + +Sends event to all clients. One-way: clients run their listener but do not answer + ```ts -rpc.emitClientEveryone('serverClientEvent', someData) +await rpc.emitClientEveryone('serverClientEvent', someData) ``` + ### onWebview + Listens to webview event + ```ts rpc.onWebview('webviewServerEvent', (player, arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offWebview + Stops listening to webview event + ```ts rpc.offWebview('webviewServerEvent') ``` + ### emitWebview -Sends event to specified webview + +Sends event to the webview of specified player + ```ts const response = await rpc.emitWebview(playerServerId, 'serverWebviewEvent', someData) // response will come from webview listener with returned data ``` + ### onSelf + Listens to server event + ```ts rpc.onSelf('serverEvent', (arg1, arg2, ...rest) => { - // logic + // logic return someData // this will be forwarded back to caller }) ``` + ### offSelf + Stops listening to server event + ```ts rpc.offSelf('serverEvent') ``` + ### emitSelf + Sends event to server + ```ts const response = await rpc.emitSelf('serverEvent', someData) // response will come from server listener with returned data ``` + ### onCommand -Registers chat command. Since arguments are untyped you must validate them yourself + +Registers chat command. `args` are the raw strings typed after the command, validate them yourself. With `restricted` set to `true` only players with the ACE permission `command.` can use it (defaults to `false`) + ```ts -rpc.onCommand('serverCommand', (player, args, commandRaw) => { +rpc.onCommand('serverCommand', (player, args, rawCommand) => { // logic -}) +}, true /* restricted */) ``` + ### onNativeEvent + Listens to native server event ([reference](https://docs.fivem.net/docs/scripting-reference/events/server-events/)) + ```ts rpc.onNativeEvent('playerJoining', (source, oldId) => { // logic }) ``` -## Client ([source](https://github.com/rilaxik/fivem-rpc/blob/master/rpc/src/core/client.ts)) +## Client ([source](src/core/client.ts)) + ### onServer + Listens to server event + ```ts rpc.onServer('serverClientEvent', (arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offServer + Stops listening to server event + ```ts rpc.offServer('serverClientEvent') ``` + ### emitServer + Sends event to server + ```ts const response = await rpc.emitServer('clientServerEvent', someData) -// response will come from webview listener with returned data +// response will come from server listener with returned data ``` + ### onWebview + Listens to webview event + ```ts rpc.onWebview('webviewClientEvent', (arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offWebview + Stops listening to webview event + ```ts rpc.offWebview('webviewClientEvent') ``` + ### emitWebview -Sends event to specified webview + +Sends event to own webview + ```ts const response = await rpc.emitWebview('clientWebviewEvent', someData) // response will come from webview listener with returned data ``` + ### onSelf + Listens to client event + ```ts rpc.onSelf('clientEvent', (arg1, arg2, ...rest) => { - // logic + // logic return someData // this will be forwarded back to caller }) ``` + ### offSelf + Stops listening to client event + ```ts rpc.offSelf('clientEvent') ``` + ### emitSelf + Sends event to client + ```ts const response = await rpc.emitSelf('clientEvent', someData) // response will come from client listener with returned data ``` + ### onCommand -Registers chat command. Since arguments are untyped you must validate them yourself + +Registers chat command. `args` are the raw strings typed after the command, validate them yourself + ```ts -rpc.onCommand('clientCommand', (player, args, commandRaw) => { +rpc.onCommand('clientCommand', (player, args, rawCommand) => { // logic }) ``` + ### onNativeEvent + Listens to native client event ([reference](https://docs.fivem.net/docs/scripting-reference/events/client-events/)) + ```ts rpc.onNativeEvent('entityDamaged', (victim, culprit, weapon, baseDamage) => { // logic }) ``` + ### onNativeNetworkEvent + Listens to native client network event ([reference](https://docs.fivem.net/docs/game-references/game-events/)) + ```ts rpc.onNativeNetworkEvent('CEventShockingCarCrash', (entities, eventEntity, data) => { // logic }) ``` + ### setWebviewFocus + Sets or removes focus and cursor from own webview + ```ts rpc.setWebviewFocus(true /* focus */, true /* show cursor */) ``` -## Webview ([source](https://github.com/rilaxik/fivem-rpc/blob/master/rpc/src/core/webview.ts)) +## Webview ([source](src/core/webview.ts)) + ### onClient + Listens to client event + ```ts rpc.onClient('clientWebviewEvent', (arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offClient + Stops listening to client event + ```ts rpc.offClient('clientWebviewEvent') ``` + ### emitClient + Sends event to own client + ```ts -const response = await rpc.emitClient('webviewClientEvent', someData) +const response = await rpc.emitClient('webviewClientEvent', someData) // response will come from client listener with returned data ``` + ### onServer + Listens to server event + ```ts rpc.onServer('serverWebviewEvent', (arg1, arg2, ...rest) => { // logic return someData // this will be forwarded back to caller }) ``` + ### offServer + Stops listening to server event + ```ts rpc.offServer('serverWebviewEvent') ``` + ### emitServer + Sends event to server + ```ts const response = await rpc.emitServer('webviewServerEvent', someData) -// response will come from webview listener with returned data +// response will come from server listener with returned data ``` + ### onSelf + Listens to webview event + ```ts rpc.onSelf('webviewEvent', (arg1, arg2, ...rest) => { - // logic + // logic return someData // this will be forwarded back to caller }) ``` + ### offSelf + Stops listening to webview event + ```ts rpc.offSelf('webviewEvent') ``` + ### emitSelf + Sends event to webview + ```ts const response = await rpc.emitSelf('webviewEvent', someData) // response will come from webview listener with returned data ``` -# License -Licensed under Custom Attribution-NoDerivs Software License +## Using with AI agents + +The package ships an [Agent Skill](https://agentskills.io) with the directions, rules, typing and error fixes above. Copy it into your agent's skills folder, e.g. for Claude Code: + +```bash +cp -r node_modules/@entityseven/fivem-rpc/skills/fivem-rpc .claude/skills/ +``` + +Copy it again after upgrading. Every method also carries TSDoc with its direction and matching listener + +## License + +Licensed under the [Custom Attribution-NoDerivs Software License](license.md) diff --git a/rpc/skills/fivem-rpc/SKILL.md b/rpc/skills/fivem-rpc/SKILL.md new file mode 100644 index 0000000..77f0dc8 --- /dev/null +++ b/rpc/skills/fivem-rpc/SKILL.md @@ -0,0 +1,105 @@ +--- +name: fivem-rpc +description: Use when writing FiveM server, client or NUI (webview) code that calls between environments with @entityseven/fivem-rpc (createRPC, emitServer, onClient, emitWebview, onServer, ...), or when declaring its typed events in @entityseven/fivem-rpc-shared-types. +--- + +# @entityseven/fivem-rpc + +Typed async calls between FiveM server, client and webview (NUI). Every `emit*` resolves with the return value of the matching `on*` listener in another environment + +## Setup + +One instance per environment, created once in a local module and imported everywhere else: + +```ts +// server/rpc.ts, same in client/rpc.ts with env 'client' and webview/rpc.ts with env 'webview' +import { createRPC } from '@entityseven/fivem-rpc' +export const rpc = createRPC({ env: 'server' }) // options: debug (false), timeout (5000 ms, 0 = none) +``` + +## Directions + +Method names are relative to the environment: `onClient` on the server listens to clients, `onClient` in the webview listens to its client + +| From | Call | To | Listener | Typed by | +| ------- | ------------------------------------- | ----------- | ------------------------------------- | ------------------------- | +| server | `emitClient(player, event, ...args)` | client | `onServer` | `RPCEvents_ServerClient` | +| server | `emitClientEveryone(event, ...args)` | all clients | `onServer`, no response | `RPCEvents_ServerClient` | +| server | `emitWebview(player, event, ...args)` | webview | `onServer`, via client | `RPCEvents_ServerWebview` | +| server | `emitSelf(event, ...args)` | server | `onSelf` | `RPCEvents_Server` | +| client | `emitServer(event, ...args)` | server | `onClient`, player first | `RPCEvents_ClientServer` | +| client | `emitWebview(event, ...args)` | webview | `onClient` | `RPCEvents_ClientWebview` | +| client | `emitSelf(event, ...args)` | client | `onSelf` | `RPCEvents_Client` | +| webview | `emitServer(event, ...args)` | server | `onWebview`, player first, via client | `RPCEvents_WebviewServer` | +| webview | `emitClient(event, ...args)` | client | `onWebview` | `RPCEvents_WebviewClient` | +| webview | `emitSelf(event, ...args)` | webview | `onSelf` | `RPCEvents_Webview` | + +Commands registered with `onCommand` are typed by `RPCCommands_Server` and `RPCCommands_Client` + +## Rules + +- every `emit*` needs its listener registered in the target environment (see Directions), otherwise it rejects with `EVENT_NOT_REGISTERED` +- webview <-> server always goes through the player's client: the client must call `createRPC({ env: 'client' })` even with no listeners, otherwise those calls time out +- one listener per event and direction: registering the same name again replaces it, `off*` removes it +- always `await` or `.catch()` an `emit*`: it rejects with `RPCError`. `emitClientEveryone` is one-way and resolves once sent +- server `onClient` and `onWebview` listeners get the caller's server id first, taken from FiveM `source`. Use it, never trust player ids passed as arguments +- arguments and return values travel as JSON: pass plain data (no functions, class instances, `Map`, `Set`) +- never register FiveM events or NUI callbacks named `__rpc:*`, the library owns them +- `onNativeEvent` and `onNativeNetworkEvent` only accept names from `NATIVE_SERVER_EVENTS`, `NATIVE_CLIENT_EVENTS` and `NATIVE_CLIENT_NETWORK_EVENTS`. Use FiveM `on(name, cb)` for anything else + +## Typing + +Declare events in one `.d.ts` file included by the `tsconfig.json` of every environment. Without declarations every name, argument and result is `any` + +```ts +// shared/rpc.d.ts +import '@entityseven/fivem-rpc-shared-types' // required: without it the declaration replaces the module + +declare module '@entityseven/fivem-rpc-shared-types' { + interface RPCEvents_WebviewServer { + // event name(arguments): value the listener returns + buyItem(item: string): boolean + } + interface RPCEvents_ServerClient { + itemBought(item: string): void + } + interface RPCCommands_Server { + ban: true // commands are keys, the value is not used + } +} +``` + +Leave an interface empty to keep that direction untyped. Declare plain return values, listeners may still be `async` + +## Example + +```ts +// server +rpc.onWebview('buyItem', async (player, item) => { + const ok = await chargePlayer(player, item) + if (ok) await rpc.emitClient(player, 'itemBought', item) + return ok +}) + +// client +rpc.onServer('itemBought', item => { + // update HUD, play a sound +}) + +// webview +const ok = await rpc.emitServer('buyItem', 'water') +``` + +## Errors + +`RPCError.code` is one of `RPCErrors`, the message names the fix + +| Code | Cause | Fix | +| ---------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `EVENT_NOT_REGISTERED` | no listener for the event on the target | register the listener from the Directions table on the target | +| `TIMEOUT` | no response within `timeout` (default 5000 ms) | listener must return or resolve; client needs `createRPC` for webview <-> server; raise `timeout` | +| `HANDLER_ERROR` | the listener threw, its message is included | fix the listener, the stack is logged with `console.error` on the target | +| `UNKNOWN_NATIVE` | `onNative*` name not in its `NATIVE_*` list | use FiveM `on(name, cb)` | +| `UNKNOWN_ENVIRONMENT` | `createRPC` got an `env` other than server, client, webview | fix `env` | + +Debug with `createRPC({ env, debug: true })`: logs every registration, call and payload diff --git a/rpc/src/core/base.ts b/rpc/src/core/base.ts new file mode 100644 index 0000000..1f34c94 --- /dev/null +++ b/rpc/src/core/base.ts @@ -0,0 +1,208 @@ +import { Emitter, type Handler } from '../utils/emitter' +import { + handlerErrorMessage, + notRegisteredMessage, + RPCError, +} from '../utils/errors' +import { generateUUID, isRPCState, parse } from '../utils/funcs' +import { Pending } from '../utils/pending' +import { + type RPCConfig, + type RPCEnvironment, + RPCErrors, + type RPCState, + type RPCStateRaw, +} from '../utils/types' + +/** + * **Internal** Shared by `RPCInstanceServer`, `RPCInstanceClient` and + * `RPCInstanceWebview`. + */ +export class RPCInstanceBase { + protected readonly env: RPCEnvironment + protected readonly debug: boolean + protected readonly _emitterLocal = new Emitter() + protected readonly _pending: Pending + + constructor(cfg: RPCConfig) { + this.env = cfg.env + this.debug = cfg.debug ?? false + this._pending = new Pending(cfg.timeout ?? 5000) + } + + /** Registers `cb` for `event` on `emitter`; `method` is only for logs */ + protected listen( + emitter: Emitter, + method: string, + event: string, + cb: Handler, + ): this { + this.log(`${method} ${event}`) + emitter.on(event, cb) + return this + } + + /** Unregisters `cb` for `event` on `emitter`; `method` is only for logs */ + protected unlisten(emitter: Emitter, method: string, event: string): this { + this.log(`${method} ${event}`) + emitter.off(event) + return this + } + + /** Builds an event payload sent from this environment to `to` */ + protected request( + event: string, + to: RPCEnvironment, + args: unknown[], + player: number | null, + type: 'event' | 'broadcast' = 'event', + ): RPCState { + return { + event, + uuid: generateUUID(), + calledFrom: this.env, + calledTo: to, + error: null, + data: args, + player, + type, + } + } + + /** Calls a listener registered with `onSelf` in this environment */ + protected emitLocal(event: string, args: unknown[]): Promise { + this.log(`emitSelf ${event}`) + if (!this._emitterLocal.has(event)) { + throw new RPCError( + RPCErrors.EVENT_NOT_REGISTERED, + notRegisteredMessage(event, this.env, this.env), + { event, uuid: '', from: this.env, to: this.env }, + ) + } + return this._emitterLocal.emit(event, ...args) + } + + /** + * Settles the call waiting for `response`; ignores late or unexpected ones. + * + * @param peer - who sent the response (server: `source`) + */ + protected settle(response: RPCState, peer?: number): void { + const found = response.error + ? this._pending.reject( + response.uuid, + RPCError.fromResponse(response), + peer, + ) + : this._pending.resolve(response.uuid, response.data?.[0], peer) + + if (!found) this.logIgnored(response) + } + + /** + * Parses and validates an incoming payload. Anything else sent on the RPC + * channels (broken JSON, other shapes) is dropped: `null`. + */ + protected accept(input: RPCStateRaw | unknown): RPCState | null { + const payload = + typeof input === 'string' + ? parse(input as RPCStateRaw) + : isRPCState(input) + ? input + : null + + if (!payload) { + this.log(`dropped invalid payload ${String(input).slice(0, 200)}`) + return null + } + + this.log( + `accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, + ) + return payload + } + + /** + * Runs the listener for `request` and builds the response to send back. + * Never throws: a missing listener or a thrown error ends up in `error`. + * + * @param prefix - arguments passed before the request data (server: player) + */ + protected async dispatch( + emitter: Emitter, + request: RPCState, + ...prefix: unknown[] + ): Promise { + if (!emitter.has(request.event)) { + return this.errorResponse( + request, + new RPCError( + RPCErrors.EVENT_NOT_REGISTERED, + notRegisteredMessage(request.event, this.env, request.calledFrom), + ), + ) + } + + try { + const data = await emitter.emit( + request.event, + ...prefix, + ...(request.data ?? []), + ) + return this.response(request, [data], null) + } catch (e) { + // keep the stack visible where the listener lives + console.error(e) + return this.errorResponse( + request, + new RPCError( + RPCErrors.HANDLER_ERROR, + handlerErrorMessage(request.event, this.env, e), + ), + ) + } + } + + /** Builds the response to `request` carrying `error` */ + protected errorResponse(request: RPCState, error: unknown): RPCState { + const rpcError = + error instanceof RPCError + ? error + : new RPCError( + RPCErrors.HANDLER_ERROR, + handlerErrorMessage(request.event, this.env, error), + ) + return this.response(request, null, { + code: rpcError.code, + message: rpcError.message, + }) + } + + /** Debug-only log, prefixed with the environment */ + protected log(message: string): void { + if (this.debug) console.log(`[RPC]:${this.env}:${message}`) + } + + protected logIgnored(response: RPCState): void { + this.log( + `ignored response ${response.event} ${response.uuid} (no pending call, possibly timed out)`, + ) + } + + private response( + request: RPCState, + data: RPCState['data'], + error: RPCState['error'], + ): RPCState { + return { + event: request.event, + uuid: request.uuid, + calledFrom: this.env, + calledTo: request.calledFrom, + error, + data, + player: request.player, + type: 'response', + } + } +} diff --git a/rpc/src/core/client.ts b/rpc/src/core/client.ts index 1bc064d..852a4c9 100644 --- a/rpc/src/core/client.ts +++ b/rpc/src/core/client.ts @@ -1,6 +1,8 @@ import type * as s from '@entityseven/fivem-rpc-shared-types' + import { Emitter } from '../utils/emitter' -import { generateUUID, parse, stringify, stringifyWeb } from '../utils/funcs' +import { RPCError, unknownNativeMessage } from '../utils/errors' +import { stringify, stringifyWeb } from '../utils/funcs' import { NATIVE_CLIENT_EVENTS, NATIVE_CLIENT_NETWORK_EVENTS, @@ -10,52 +12,57 @@ import { RPCErrors, RPCEvents, type RPCNativeClientEvents, - type RPCNativeClientNetworksEvents, + type RPCNativeClientNetworkEvents, type RPCState, type RPCStateRaw, type RPCStateWeb, } from '../utils/types' -import { Wrapper } from './wrapper' +import type { + RPCCommandName, + RPCEventArgs, + RPCEventName, + RPCEventResult, + RPCListener, +} from '../utils/typing' +import { RPCInstanceBase } from './base' -declare function onNet(eventName: string, callback: Function): void -declare function emitNet(eventName: string, ...args: unknown[]): void -declare function on(eventName: string, callback: Function): void -declare function RegisterNuiCallbackType(callbackType: string): void -declare function GetPlayerServerId(player: number): number -declare function PlayerId(): number -declare function SetNuiFocus(hasFocus: boolean, hasCursor: boolean): void -declare function RegisterCommand( - commandName: string, - handler: Function, - restricted: boolean, -): void -declare function SendNuiMessage(jsonString: string): boolean - -export class RPCInstanceClient extends Wrapper { +/** + * RPC instance for client code, returned by `createRPC({ env: 'client' })`. + * Create one per client and import it from your own module. + * + * - `on*` registers the listener that answers calls from one direction. One + * listener per event: registering the same name again replaces it, `off*` + * removes it + * - `emit*` calls the listener on the target and resolves with its return + * value, or rejects with {@link RPCError} + * - also relays calls between its webview and the server, so every client + * needs an instance even without listeners of its own + */ +export class RPCInstanceClient extends RPCInstanceBase { private readonly _emitterServer: Emitter - private readonly _pendingServer: Emitter private readonly _emitterWeb: Emitter - private readonly _pendingWeb: Emitter - private readonly _pendingWebToServer: Emitter constructor(props: RPCConfig<'client'>) { super(props) this._emitterServer = new Emitter() - this._pendingServer = new Emitter() this._emitterWeb = new Emitter() - this._pendingWeb = new Emitter() - this._pendingWebToServer = new Emitter() - this.console.log('[RPC] Initialized Client') + console.log('[RPC] Initialized Client') onNet(RPCEvents.LISTENER_SERVER, this._handleServer.bind(this)) RegisterNuiCallbackType(RPCEvents.LISTENER_WEB) on( `__cfx_nui:${RPCEvents.LISTENER_WEB}`, - async (data: RPCState, callback: (res: unknown) => void) => { - const res = await this._handleWeb(data) - callback(res) + async (data: unknown, callback: (res: unknown) => void) => { + const payload = this.accept(data) + if (!payload) return callback({ status: 'invalid' }) + + try { + callback(await this._handleWeb(payload)) + } catch (e) { + callback(this.errorResponse(payload, e)) + } }, ) } @@ -63,40 +70,21 @@ export class RPCInstanceClient extends Wrapper { // ===== HANDLERS ===== private async _handleServer(payloadRaw: RPCStateRaw) { - try { - parse(payloadRaw) - } catch (e) { - throw new Error(RPCErrors.INVALID_DATA) - } - const payload = parse(payloadRaw) + const payload = this.accept(payloadRaw) + if (!payload) return - if (this.debug) { - this.console.log( - `[RPC]:client:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - - if (payload.type === 'event') { + if (payload.type === 'event' || payload.type === 'broadcast') { if (payload.calledTo === 'client') { - this.verifyEvent(this._emitterServer, payload) + const response = await this.dispatch(this._emitterServer, payload) - const responseData = await this._emitterServer.emit( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - - const response: RPCState = { - event: payload.event, - uuid: payload.uuid, - calledFrom: 'client', - calledTo: 'server', - error: null, - data: [responseData], - player: payload.player, - type: 'response', + if (payload.type === 'event') { + emitNet(RPCEvents.LISTENER_CLIENT, stringify(response)) + } else if (response.error) { + // nobody waits for a broadcast, so its failures only show up here + this.log( + `broadcast ${payload.event} failed: ${response.error.message}`, + ) } - - emitNet(RPCEvents.LISTENER_CLIENT, stringify(response)) } if (payload.calledTo === 'webview') { this._sendWebMessage({ @@ -107,55 +95,36 @@ export class RPCInstanceClient extends Wrapper { } if (payload.type === 'response') { if (payload.calledTo === 'client') { - await this._pendingServer.emit( - payload.uuid, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + this.settle(payload) } if (payload.calledTo === 'webview') { - await this._pendingWebToServer.emit( - payload.uuid, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + // relayed webview -> server call: the webview gets the whole response + if (!this._pending.resolve(payload.uuid, payload)) { + this.logIgnored(payload) + } } } } private async _handleWeb(payload: RPCState): Promise { - if (this.debug) { - this.console.log( - `[RPC]:client:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - if (payload.type === 'event') { if (payload.calledTo === 'client') { - return await this._emitterWeb.emit( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + return this.dispatch(this._emitterWeb, payload) } if (payload.calledTo === 'server') { - payload.player = GetPlayerServerId(PlayerId()) emitNet(RPCEvents.LISTENER_WEB, stringify(payload)) - return new Promise(res => { - this._pendingWebToServer.once(payload.uuid, res) - }) + return this._pending.wait(payload) } } if (payload.type === 'response') { if (payload.calledTo === 'client') { - await this._pendingWeb.emit( - payload.uuid, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + this.settle(payload) return { status: 'ok' } } if (payload.calledTo === 'server') { - payload.player = GetPlayerServerId(PlayerId()) emitNet(RPCEvents.LISTENER_WEB, stringify(payload)) return { status: 'ok' } @@ -166,240 +135,237 @@ export class RPCInstanceClient extends Wrapper { // ===== SERVER ===== - public onServer< - EventName extends keyof s.RPCEvents_ServerClient, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, + /** + * Listens for `emitClient` and `emitClientEveryone` calls from the server + * (server -> client). For `emitClientEveryone` the return value is not sent. + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onServer('askTrade', offer => showTradeDialog(offer)) + */ + public onServer>( + eventName: EventName, + cb: RPCListener, + ): this { + return this.listen(this._emitterServer, 'onServer', eventName, cb) + } + + /** Removes the `onServer` listener for `eventName` */ + public offServer>( + eventName: EventName, + ): this { + return this.unlisten(this._emitterServer, 'offServer', eventName) + } + + /** + * Calls the server's `onClient` listener (client -> server) and resolves + * with its return value. The server listener gets this player's id first. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const money = await rpc.emitServer('getMoney', 'bank') + */ + public async emitServer< + EventName extends RPCEventName, >( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, - ): this { - if (this.debug) { - this.console.log(`[RPC]:onServer ${eventName}`) - } - - this._emitterServer.on(eventName, cb) - - return this - } - - public offServer( - eventName: EventName, - ): this { - if (this.debug) { - this.console.log(`[RPC]:offServer ${eventName}`) - } - - this._emitterServer.off(eventName) - - return this - } - - public async emitServer< - EventName extends keyof s.RPCEvents_ClientServer, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'client', - calledTo: 'server', - error: null, - data: args.length ? args : null, - player: GetPlayerServerId(PlayerId()), - type: 'event', - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'server', args, null) emitNet(RPCEvents.LISTENER_CLIENT, stringify(payload)) - return new Promise>(res => { - this._pendingServer.once(payload.uuid, res) - }) + return this._pending.wait(payload) } // ===== WEBVIEW ===== - public onWebview< - EventName extends keyof s.RPCEvents_WebviewClient, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, + /** + * Listens for `emitClient` calls from this player's webview + * (webview -> client). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onWebview('getPosition', () => GetEntityCoords(PlayerPedId(), false)) + */ + public onWebview>( + eventName: EventName, + cb: RPCListener, + ): this { + return this.listen(this._emitterWeb, 'onWebview', eventName, cb) + } + + /** Removes the `onWebview` listener for `eventName` */ + public offWebview>( + eventName: EventName, + ): this { + return this.unlisten(this._emitterWeb, 'offWebview', eventName) + } + + /** + * Calls the `onClient` listener in this player's webview (client -> webview) + * and resolves with its return value. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const choice = await rpc.emitWebview('openMenu', items) + */ + public async emitWebview< + EventName extends RPCEventName, >( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, - ): this { - if (this.debug) { - this.console.log(`[RPC]:onWebview ${eventName}`) - } - - this._emitterWeb.on(eventName, cb) - - return this - } - - public offWebview( - eventName: EventName, - ): this { - if (this.debug) { - this.console.log(`[RPC]:offWebview ${eventName}`) - } - - this._emitterWeb.off(eventName) - - return this - } - - public async emitWebview< - EventName extends keyof s.RPCEvents_ClientWebview, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'client', - calledTo: 'webview', - error: null, - data: args.length ? args : null, - player: PlayerId(), - type: 'event', - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'webview', args, null) this._sendWebMessage({ origin: RPCEvents.LISTENER_CLIENT, data: payload, }) - return new Promise>(res => { - this._pendingWeb.once(payload.uuid, res) - }) + return this._pending.wait(payload) } // ===== SELF ===== - public onSelf< - EventName extends keyof s.RPCEvents_Client, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, - >( + /** + * Listens for `emitSelf` calls in this environment (client -> client). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onSelf('add', (a, b) => a + b) + */ + public onSelf>( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, + cb: RPCListener, ): this { - if (this.debug) { - this.console.log(`[RPC]:onSelf ${eventName}`) - } - - this._emitterLocal.on(eventName, cb) - - return this + return this.listen(this._emitterLocal, 'onSelf', eventName, cb) } - public offSelf( + /** Removes the `onSelf` listener for `eventName` */ + public offSelf>( eventName: EventName, ): this { - if (this.debug) { - this.console.log(`[RPC]:offSelf ${eventName}`) - } - - this._emitterLocal.off(eventName) - - return this + return this.unlisten(this._emitterLocal, 'offSelf', eventName) } - public async emitSelf< - EventName extends keyof s.RPCEvents_Client, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'client', - calledTo: 'client', - error: null, - data: args.length ? args : null, - player: null, - type: 'event', - } - - if (this.debug) { - this.console.log( - `[RPC]:accepted ${payload.event} from ${payload.calledFrom}`, - ) - } - - this.verifyEvent(this._emitterLocal, payload) - - return await this._emitterLocal.emit>( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + /** + * Calls this environment's own `onSelf` listener directly and resolves with + * its return value. No timeout; errors thrown by the listener reach the caller + * unchanged. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` if no `onSelf` listener exists + * + * @example + * const total = await rpc.emitSelf('add', 2, 3) + */ + public async emitSelf>( + eventName: EventName, + ...args: RPCEventArgs + ): Promise> { + return this.emitLocal(eventName, args) } // ===== OTHER ===== - public onCommand< - CommandName extends s.RPCCommands_Client, - CallbackArguments extends unknown[], - >( + /** + * Registers a chat command (FiveM `RegisterCommand`). + * + * @param cb - gets FiveM's `source`, the strings typed after the command and + * the full command line. Validate `args` yourself + * + * @example + * rpc.onCommand('coords', () => console.log(GetEntityCoords(PlayerPedId(), false))) + */ + public onCommand>( command: CommandName, - cb: (player: number, args: CallbackArguments, commandRaw: string) => void, + cb: (player: number, args: string[], rawCommand: string) => void, ): this { - if (this.debug) { - this.console.log(`[RPC]:onCommand ${command}`) - } + this.log(`onCommand ${command}`) RegisterCommand(command, cb, false) return this } - public onNativeEvent< - EventName extends keyof RPCNativeClientEvents, - CallbackArguments extends Parameters, - >(eventName: EventName, cb: (...args: CallbackArguments) => void): this { + /** + * Listens to a native FiveM client event, e.g. `entityDamaged`. + * + * @throws {@link RPCError} `UNKNOWN_NATIVE` if `eventName` is not in + * `NATIVE_CLIENT_EVENTS`. Register other events with FiveM's `on` directly + * + * @example + * rpc.onNativeEvent('entityDamaged', (victim, culprit) => console.log(victim)) + */ + public onNativeEvent( + eventName: EventName, + cb: (...args: Parameters) => void, + ): this { if (!NATIVE_CLIENT_EVENTS.includes(eventName)) { - throw new Error(RPCErrors.UNKNOWN_NATIVE) + throw new RPCError( + RPCErrors.UNKNOWN_NATIVE, + unknownNativeMessage(eventName, 'NATIVE_CLIENT_EVENTS'), + ) } - if (this.debug) { - this.console.log(`[RPC]:onNativeEvent ${eventName}`) - } + this.log(`onNativeEvent ${eventName}`) on(eventName, cb) return this } + /** + * Listens to a native game event, e.g. `CEventShockingCarCrash`. + * + * @throws {@link RPCError} `UNKNOWN_NATIVE` if `eventName` is not in + * `NATIVE_CLIENT_NETWORK_EVENTS`. Register other events with FiveM's `on` + * directly + * + * @example + * rpc.onNativeNetworkEvent('CEventShockingCarCrash', (entities, eventEntity) => {}) + */ public onNativeNetworkEvent< - EventName extends keyof RPCNativeClientNetworksEvents, - CallbackArguments extends Parameters< - RPCNativeClientNetworksEvents[EventName] - >, - >(eventName: EventName, cb: (...args: CallbackArguments) => void): this { + EventName extends keyof RPCNativeClientNetworkEvents, + >( + eventName: EventName, + cb: (...args: Parameters) => void, + ): this { if (!NATIVE_CLIENT_NETWORK_EVENTS.includes(eventName)) { - throw new Error(RPCErrors.UNKNOWN_NATIVE) + throw new RPCError( + RPCErrors.UNKNOWN_NATIVE, + unknownNativeMessage(eventName, 'NATIVE_CLIENT_NETWORK_EVENTS'), + ) } - if (this.debug) { - this.console.log(`[RPC]:onNativeNetworkEvent ${eventName}`) - } + this.log(`onNativeNetworkEvent ${eventName}`) on(eventName, cb) return this } + /** + * Focuses this player's webview (FiveM `SetNuiFocus`). + * + * @param hasFocus - the webview receives keyboard input + * @param hasCursor - the mouse cursor is shown + * + * @example + * rpc.setWebviewFocus(true, true) + */ public setWebviewFocus(hasFocus: boolean, hasCursor: boolean): this { - if (this.debug) { - this.console.log(`[RPC]:setWebviewFocus ${hasFocus} ${hasCursor}`) - } + this.log(`setWebviewFocus ${hasFocus} ${hasCursor}`) SetNuiFocus(hasFocus, hasCursor) diff --git a/rpc/src/core/server.ts b/rpc/src/core/server.ts index 3807217..7a7d550 100644 --- a/rpc/src/core/server.ts +++ b/rpc/src/core/server.ts @@ -1,383 +1,301 @@ import type * as s from '@entityseven/fivem-rpc-shared-types' + import { Emitter } from '../utils/emitter' -import { generateUUID, parse, stringify } from '../utils/funcs' +import { RPCError, unknownNativeMessage } from '../utils/errors' +import { stringify } from '../utils/funcs' import { NATIVE_SERVER_EVENTS } from '../utils/native' import { type RPCConfig, RPCErrors, RPCEvents, type RPCNativeServerEvents, - type RPCState, type RPCStateRaw, } from '../utils/types' -import { Wrapper } from './wrapper' +import type { + RPCCommandName, + RPCEventArgs, + RPCEventName, + RPCEventResult, + RPCListener, +} from '../utils/typing' +import { RPCInstanceBase } from './base' -declare function onNet(eventName: string, callback: Function): void -declare function emitNet(eventName: string, ...args: unknown[]): void -declare function on(eventName: string, callback: Function): void -declare function RegisterCommand( - commandName: string, - handler: Function, - restricted: boolean, -): void - -export class RPCInstanceServer extends Wrapper { +/** + * RPC instance for server code, returned by `createRPC({ env: 'server' })`. + * Create one per server and import it from your own module. + * + * - `on*` registers the listener that answers calls from one direction. One + * listener per event: registering the same name again replaces it, `off*` + * removes it + * - `emit*` calls the listener on the target and resolves with its return + * value, or rejects with {@link RPCError} + * - listeners for client and webview calls get the calling player's server id + * first, taken from FiveM `source` + */ +export class RPCInstanceServer extends RPCInstanceBase { private readonly _emitterClient: Emitter - private readonly _pendingClient: Emitter private readonly _emitterWeb: Emitter - private readonly _pendingWeb: Emitter constructor(props: RPCConfig<'server'>) { super(props) this._emitterClient = new Emitter() - this._pendingClient = new Emitter() this._emitterWeb = new Emitter() - this._pendingWeb = new Emitter() - this.console.log('[RPC] Initialized Server') + console.log('[RPC] Initialized Server') - onNet(RPCEvents.LISTENER_CLIENT, this._handleClient.bind(this)) - onNet(RPCEvents.LISTENER_WEB, this._handleWeb.bind(this)) + // `source` must be read synchronously, before any await + onNet(RPCEvents.LISTENER_CLIENT, (raw: RPCStateRaw) => + this._handle(raw, source, 'client', this._emitterClient), + ) + onNet(RPCEvents.LISTENER_WEB, (raw: RPCStateRaw) => + this._handle(raw, source, 'webview', this._emitterWeb), + ) } // ===== HANDLERS ===== - private async _handleClient(payloadRaw: RPCStateRaw) { - try { - parse(payloadRaw) - } catch (e) { - throw new Error(RPCErrors.INVALID_DATA) + /** + * Handles a payload from a client or its webview. + * + * @param player - FiveM `source` of the net event: the only trusted player id, + * whatever the payload claims + */ + private async _handle( + payloadRaw: RPCStateRaw, + player: number, + from: 'client' | 'webview', + emitter: Emitter, + ) { + const payload = this.accept(payloadRaw) + if (!payload || payload.calledFrom !== from) return + + // not sent by a player, e.g. a server-side trigger of the RPC channel + if (!(player > 0)) { + this.log(`dropped ${payload.event}: no player source`) + return } - const payload = parse(payloadRaw) + payload.player = player - if (this.debug) { - this.console.log( - `[RPC]:server:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - - if (payload.calledFrom === 'client') { - if (payload.type === 'event') { - this.verifyEvent(this._emitterClient, payload) - if (payload.player === null || payload.player === -1) { - payload.error = RPCErrors.NO_PLAYER - this.triggerError(payload) - return - } - - const responseData = await this._emitterClient.emit( - payload.event, - payload.player, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - - const response: RPCState = { - event: payload.event, - uuid: payload.uuid, - calledFrom: 'server', - calledTo: 'client', - error: null, - data: [responseData], - player: payload.player, - type: 'response', - } - - emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response)) - } - if (payload.type === 'response') { - await this._pendingClient.emit( - payload.uuid, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - } - } - } - - private async _handleWeb(payloadRaw: RPCStateRaw) { - try { - parse(payloadRaw) - } catch (e) { - throw new Error(RPCErrors.INVALID_DATA) - } - const payload = parse(payloadRaw) - - if (this.debug) { - this.console.log( - `[RPC]:server:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - - if (payload.calledFrom === 'webview') { - if (payload.type === 'event') { - this.verifyEvent(this._emitterWeb, payload) - if (payload.player === null || payload.player === -1) { - payload.error = RPCErrors.NO_PLAYER - this.triggerError(payload) - return - } - - const responseData = await this._emitterWeb.emit( - payload.event, - payload.player, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - - const response: RPCState = { - event: payload.event, - uuid: payload.uuid, - calledFrom: 'server', - calledTo: 'webview', - error: null, - data: [responseData], - player: payload.player, - type: 'response', - } - - emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response)) - } - if (payload.type === 'response') { - await this._pendingWeb.emit( - payload.uuid, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - } + if (payload.type === 'event') { + const response = await this.dispatch(emitter, payload, player) + emitNet(RPCEvents.LISTENER_SERVER, player, stringify(response)) + } else if (payload.type === 'response') { + this.settle(payload, player) } } // ===== CLIENT ===== - public onClient< - EventName extends keyof s.RPCEvents_ClientServer, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, - >( + /** + * Listens for `emitServer` calls from clients (client -> server). + * + * @param cb - gets the calling player's server id first (from FiveM + * `source`, never from the payload), then the event arguments. Its return + * value (awaited) is sent back to the caller + * + * @example + * rpc.onClient('getMoney', (player, account) => getMoney(player, account)) + */ + public onClient>( eventName: EventName, - cb: ( - player: number, - ...args: CallbackArguments - ) => Awaited | Promise>, + cb: RPCListener, ): this { - if (this.debug) { - this.console.log(`[RPC]:onClient ${eventName}`) - } - - this._emitterClient.on(eventName, cb) - - return this + return this.listen(this._emitterClient, 'onClient', eventName, cb) } - public offClient( + /** Removes the `onClient` listener for `eventName` */ + public offClient>( eventName: EventName, ): this { - if (this.debug) { - this.console.log(`[RPC]:offClient ${eventName}`) - } - - this._emitterClient.off(eventName) - - return this + return this.unlisten(this._emitterClient, 'offClient', eventName) } + /** + * Calls the `onServer` listener on one client (server -> client) and + * resolves with its return value. + * + * @param player - server id of the target player + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const accepted = await rpc.emitClient(player, 'askTrade', offer) + */ public async emitClient< - EventName extends keyof s.RPCEvents_ServerClient, - Arguments extends Parameters, - Response extends ReturnType, + EventName extends RPCEventName, >( player: number, eventName: EventName, - ...args: Arguments - ): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'server', - calledTo: 'client', - error: null, - data: args.length ? args : null, - player: player, - type: 'event', - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'client', args, player) emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload)) - return new Promise>(res => { - this._pendingClient.once(payload.uuid, res) - }) + return this._pending.wait(payload, player) } + /** + * Runs the `onServer` listener on every client (server -> all clients). + * One-way: resolves once sent, clients do not answer and their failures stay + * on the client. + * + * @example + * await rpc.emitClientEveryone('weatherChanged', 'RAIN') + */ public async emitClientEveryone< - EventName extends keyof s.RPCEvents_ServerClient, - Arguments extends Parameters, - >(eventName: EventName, ...args: Arguments): Promise { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'server', - calledTo: 'client', - error: null, - data: args.length ? args : null, - player: -1, - type: 'event', - } + EventName extends RPCEventName, + >( + eventName: EventName, + ...args: RPCEventArgs + ): Promise { + const payload = this.request(eventName, 'client', args, -1, 'broadcast') emitNet(RPCEvents.LISTENER_SERVER, -1, stringify(payload)) } // ===== WEBVIEW ===== - public onWebview< - EventName extends keyof s.RPCEvents_WebviewServer, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, - >( + /** + * Listens for `emitServer` calls from webviews (webview -> server, relayed + * by the player's client). + * + * @param cb - gets the calling player's server id first (from FiveM + * `source`, never from the payload), then the event arguments. Its return + * value (awaited) is sent back to the caller + * + * @example + * rpc.onWebview('buyItem', (player, item) => shop.buy(player, item)) + */ + public onWebview>( eventName: EventName, - cb: ( - player: number, - ...args: CallbackArguments - ) => Awaited | Promise>, + cb: RPCListener, ): this { - if (this.debug) { - this.console.log(`[RPC]:onWebview ${eventName}`) - } - - this._emitterWeb.on(eventName, cb) - - return this + return this.listen(this._emitterWeb, 'onWebview', eventName, cb) } - public offWebview( + /** Removes the `onWebview` listener for `eventName` */ + public offWebview>( eventName: EventName, ): this { - if (this.debug) { - this.console.log(`[RPC]:offWebview ${eventName}`) - } - - this._emitterWeb.off(eventName) - - return this + return this.unlisten(this._emitterWeb, 'offWebview', eventName) } + /** + * Calls the `onServer` listener in one player's webview (server -> webview, + * relayed by that player's client) and resolves with its return value. + * + * @param player - server id of the target player + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const confirmed = await rpc.emitWebview(player, 'confirmPurchase', item) + */ public async emitWebview< - EventName extends keyof s.RPCEvents_ServerWebview, - Arguments extends Parameters, - Response extends ReturnType, + EventName extends RPCEventName, >( player: number, eventName: EventName, - ...args: Arguments - ): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'server', - calledTo: 'webview', - error: null, - data: args.length ? args : null, - player: player, - type: 'event', - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'webview', args, player) emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload)) - return new Promise>(res => { - this._pendingWeb.once(payload.uuid, res) - }) + return this._pending.wait(payload, player) } // ===== SELF ===== - public onSelf< - EventName extends keyof s.RPCEvents_Server, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, - >( + /** + * Listens for `emitSelf` calls in this environment (server -> server). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onSelf('add', (a, b) => a + b) + */ + public onSelf>( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, + cb: RPCListener, ): this { - if (this.debug) { - this.console.log(`[RPC]:onSelf ${eventName}`) - } - - this._emitterLocal.on(eventName, cb) - - return this + return this.listen(this._emitterLocal, 'onSelf', eventName, cb) } - public offSelf( + /** Removes the `onSelf` listener for `eventName` */ + public offSelf>( eventName: EventName, ): this { - if (this.debug) { - this.console.log(`[RPC]:offSelf ${eventName}`) - } - - this._emitterLocal.off(eventName) - - return this + return this.unlisten(this._emitterLocal, 'offSelf', eventName) } - public async emitSelf< - EventName extends keyof s.RPCEvents_Server, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'server', - calledTo: 'server', - error: null, - data: args.length ? args : null, - player: null, - type: 'event', - } - - if (this.debug) { - this.console.log( - `[RPC]:accepted ${payload.event} from ${payload.calledFrom}`, - ) - } - - this.verifyEvent(this._emitterLocal, payload) - - return await this._emitterLocal.emit>( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + /** + * Calls this environment's own `onSelf` listener directly and resolves with + * its return value. No timeout; errors thrown by the listener reach the caller + * unchanged. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` if no `onSelf` listener exists + * + * @example + * const total = await rpc.emitSelf('add', 2, 3) + */ + public async emitSelf>( + eventName: EventName, + ...args: RPCEventArgs + ): Promise> { + return this.emitLocal(eventName, args) } // ===== OTHER ===== - public onCommand< - CommandName extends s.RPCCommands_Server, - CallbackArguments extends unknown[], - >( + /** + * Registers a chat command (FiveM `RegisterCommand`). + * + * @param cb - gets the player's server id (`0` for the server console), the + * strings typed after the command and the full command line. Validate + * `args` yourself + * @param restricted - only players with the ACE permission `command.` + * can use it + * + * @example + * rpc.onCommand('heal', (player, args) => heal(player, Number(args[0])), true) + */ + public onCommand>( command: CommandName, - cb: (player: number, args: CallbackArguments, commandRaw: string) => void, + cb: (player: number, args: string[], rawCommand: string) => void, restricted = false, ): this { - if (this.debug) { - this.console.log(`[RPC]:onCommand ${command}`) - } + this.log(`onCommand ${command}`) RegisterCommand(command, cb, restricted) return this } - public onNativeEvent< - EventName extends keyof RPCNativeServerEvents, - CallbackArguments extends Parameters, - >(eventName: EventName, cb: (...args: CallbackArguments) => void): this { + /** + * Listens to a native FiveM server event, e.g. `playerJoining`. + * + * @throws {@link RPCError} `UNKNOWN_NATIVE` if `eventName` is not in + * `NATIVE_SERVER_EVENTS`. Register other events with FiveM's `on` directly + * + * @example + * rpc.onNativeEvent('playerJoining', (source, oldId) => console.log(source)) + */ + public onNativeEvent( + eventName: EventName, + cb: (...args: Parameters) => void, + ): this { if (!NATIVE_SERVER_EVENTS.includes(eventName)) { - throw new Error(RPCErrors.UNKNOWN_NATIVE) + throw new RPCError( + RPCErrors.UNKNOWN_NATIVE, + unknownNativeMessage(eventName, 'NATIVE_SERVER_EVENTS'), + ) } - if (this.debug) { - this.console.log(`[RPC]:onNativeEvent ${eventName}`) - } + this.log(`onNativeEvent ${eventName}`) on(eventName, cb) diff --git a/rpc/src/core/webview.ts b/rpc/src/core/webview.ts index a92cb52..39d4608 100644 --- a/rpc/src/core/webview.ts +++ b/rpc/src/core/webview.ts @@ -1,22 +1,34 @@ import type * as s from '@entityseven/fivem-rpc-shared-types' + import { Emitter } from '../utils/emitter' -import { generateUUID, stringify } from '../utils/funcs' +import { stringify } from '../utils/funcs' import { RPCEvents, type RPCConfig, type RPCState, - type RPCStateRaw, type RPCStateWeb, } from '../utils/types' -import { Wrapper } from './wrapper' +import type { + RPCEventArgs, + RPCEventName, + RPCEventResult, + RPCListener, +} from '../utils/typing' +import { RPCInstanceBase } from './base' -declare global { - interface Window { - GetParentResourceName?: () => string - } -} - -export class RPCInstanceWebview extends Wrapper { +/** + * RPC instance for webview code, returned by `createRPC({ env: 'webview' })`. + * Create one per webview and import it from your own module. + * + * - `on*` registers the listener that answers calls from one direction. One + * listener per event: registering the same name again replaces it, `off*` + * removes it + * - `emit*` calls the listener on the target and resolves with its return + * value, or rejects with {@link RPCError} + * - calls to and from the server are relayed by the player's client, which + * must run `createRPC({ env: 'client' })` + */ +export class RPCInstanceWebview extends RPCInstanceBase { private readonly _emitterClient: Emitter private readonly _emitterServer: Emitter @@ -26,75 +38,42 @@ export class RPCInstanceWebview extends Wrapper { this._emitterClient = new Emitter() this._emitterServer = new Emitter() - this.console.log('[RPC] Initialized Webview') + console.log('[RPC] Initialized Webview') - window.addEventListener('message', (e: MessageEvent) => { - if (e.data.origin === RPCEvents.LISTENER_CLIENT) { - this._handleClient(e.data.data) - } - if (e.data.origin === RPCEvents.LISTENER_SERVER) { - this._handleServer(e.data.data) - } - }) + window.addEventListener( + 'message', + (e: MessageEvent | null>) => { + const origin = e.data?.origin + // not ours, e.g. the resource's own SendNUIMessage calls + if ( + origin !== RPCEvents.LISTENER_CLIENT && + origin !== RPCEvents.LISTENER_SERVER + ) { + return + } + + const payload = this.accept(e.data?.data) + if (!payload) return + + if (origin === RPCEvents.LISTENER_CLIENT) this._handleClient(payload) + else this._handleServer(payload) + }, + ) } // ===== HANDLERS ===== private async _handleClient(payload: RPCState) { - if (this.debug) { - this.console.log( - `[RPC]:webview:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - if (payload.calledFrom === 'client' && payload.type === 'event') { - this.verifyEvent(this._emitterClient, payload) + const response = await this.dispatch(this._emitterClient, payload) - const responseData = await this._emitterClient.emit( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - - const response: RPCState = { - event: payload.event, - uuid: payload.uuid, - calledFrom: 'webview', - calledTo: 'client', - error: null, - data: [responseData], - player: payload.player, - type: 'response', - } - - await this._createHttpClientRequest(response).then() + await this._createHttpClientRequest(response) } } private async _handleServer(payload: RPCState) { - if (this.debug) { - this.console.log( - `[RPC]:webview:accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, - ) - } - if (payload.calledFrom === 'server' && payload.type === 'event') { - this.verifyEvent(this._emitterServer, payload) - - const responseData = await this._emitterServer.emit( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) - - const response: RPCState = { - event: payload.event, - uuid: payload.uuid, - calledFrom: 'webview', - calledTo: 'server', - error: null, - data: [responseData], - player: payload.player, - type: 'response', - } + const response = await this.dispatch(this._emitterServer, payload) await this._createHttpClientRequest(response) } @@ -102,184 +81,166 @@ export class RPCInstanceWebview extends Wrapper { // ===== CLIENT ===== - public onClient< - EventName extends keyof s.RPCEvents_ClientWebview, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, + /** + * Listens for `emitWebview` calls from this player's client + * (client -> webview). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onClient('openMenu', items => menu.open(items)) + */ + public onClient>( + eventName: EventName, + cb: RPCListener, + ): this { + return this.listen(this._emitterClient, 'onClient', eventName, cb) + } + + /** Removes the `onClient` listener for `eventName` */ + public offClient>( + eventName: EventName, + ): this { + return this.unlisten(this._emitterClient, 'offClient', eventName) + } + + /** + * Calls the client's `onWebview` listener (webview -> client) and resolves + * with its return value. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const position = await rpc.emitClient('getPosition') + */ + public async emitClient< + EventName extends RPCEventName, >( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, - ): this { - if (this.debug) { - this.console.log(`[RPC]:onClient ${eventName}`) - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'client', args, null) - this._emitterClient.on(eventName, cb) - - return this - } - - public offClient( - eventName: EventName, - ): this { - if (this.debug) { - this.console.log(`[RPC]:offClient ${eventName}`) - } - - this._emitterClient.off(eventName) - - return this - } - - public async emitClient< - EventName extends keyof s.RPCEvents_WebviewClient, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'webview', - calledTo: 'client', - error: null, - data: args.length ? args : null, - player: null, - type: 'event', - } - - return await this._createHttpClientRequest>(payload) + return this._request(payload) } // ===== SERVER ===== - public onServer< - EventName extends keyof s.RPCEvents_ServerWebview, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, + /** + * Listens for `emitWebview` calls from the server (server -> webview, + * relayed by the client). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onServer('confirmPurchase', item => window.confirm(`Buy ${item}?`)) + */ + public onServer>( + eventName: EventName, + cb: RPCListener, + ): this { + return this.listen(this._emitterServer, 'onServer', eventName, cb) + } + + /** Removes the `onServer` listener for `eventName` */ + public offServer>( + eventName: EventName, + ): this { + return this.unlisten(this._emitterServer, 'offServer', eventName) + } + + /** + * Calls the server's `onWebview` listener (webview -> server, relayed by + * the client) and resolves with its return value. The server listener gets + * this player's id first. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` (no listener), + * `HANDLER_ERROR` (the listener threw) or `TIMEOUT` + * + * @example + * const bought = await rpc.emitServer('buyItem', 'water') + */ + public async emitServer< + EventName extends RPCEventName, >( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, - ): this { - if (this.debug) { - this.console.log(`[RPC]:onServer ${eventName}`) - } + ...args: RPCEventArgs + ): Promise> { + const payload = this.request(eventName, 'server', args, null) - this._emitterServer.on(eventName, cb) - - return this - } - - public offServer( - eventName: EventName, - ): RPCInstanceWebview { - if (this.debug) { - this.console.log(`[RPC]:offServer ${eventName}`) - } - - this._emitterServer.off(eventName) - - return this - } - - public async emitServer< - EventName extends keyof s.RPCEvents_WebviewServer, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'webview', - calledTo: 'server', - error: null, - data: args.length ? args : null, - player: null, - type: 'event', - } - - return await this._createHttpClientRequest>(payload) + return this._request(payload) } // ===== SELF ===== - public onSelf< - EventName extends keyof s.RPCEvents_Webview, - CallbackArguments extends Parameters, - CallbackReturn extends ReturnType, - >( + /** + * Listens for `emitSelf` calls in this environment (webview -> webview). + * + * @param cb - gets the event arguments. Its return value (awaited) is sent + * back to the caller + * + * @example + * rpc.onSelf('add', (a, b) => a + b) + */ + public onSelf>( eventName: EventName, - cb: ( - ...args: CallbackArguments - ) => Awaited | Promise>, + cb: RPCListener, ): this { - if (this.debug) { - this.console.log(`[RPC]:onSelf ${eventName}`) - } - - this._emitterLocal.on(eventName, cb) - - return this + return this.listen(this._emitterLocal, 'onSelf', eventName, cb) } - public offSelf( + /** Removes the `onSelf` listener for `eventName` */ + public offSelf>( eventName: EventName, ): this { - if (this.debug) { - this.console.log(`[RPC]:offSelf ${eventName}`) - } - - this._emitterLocal.off(eventName) - - return this + return this.unlisten(this._emitterLocal, 'offSelf', eventName) } - public async emitSelf< - EventName extends keyof s.RPCEvents_Webview, - Arguments extends Parameters, - Response extends ReturnType, - >(eventName: EventName, ...args: Arguments): Promise> { - const payload: RPCState = { - event: eventName, - uuid: generateUUID(), - calledFrom: 'webview', - calledTo: 'webview', - error: null, - data: args.length ? args : null, - player: null, - type: 'event', - } - - if (this.debug) { - this.console.log( - `[RPC]:accepted ${payload.event} from ${payload.calledFrom}`, - ) - } - - this.verifyEvent(this._emitterLocal, payload) - - return await this._emitterLocal.emit>( - payload.event, - ...(payload.data && payload.data.length > 0 ? payload.data : []), - ) + /** + * Calls this environment's own `onSelf` listener directly and resolves with + * its return value. No timeout; errors thrown by the listener reach the caller + * unchanged. + * + * @throws {@link RPCError} `EVENT_NOT_REGISTERED` if no `onSelf` listener exists + * + * @example + * const total = await rpc.emitSelf('add', 2, 3) + */ + public async emitSelf>( + eventName: EventName, + ...args: RPCEventArgs + ): Promise> { + return this.emitLocal(eventName, args) } // ===== UTILS ===== - private async _createHttpClientRequest( - data: RPCStateRaw | RPCState, - ): Promise { - const dataRaw = typeof data === 'string' ? data : stringify(data) + /** Sends an event to the client and waits for its response (with timeout) */ + private _request(payload: RPCState): Promise { + const response = this._pending.wait(payload) + this._createHttpClientRequest(payload).then( + res => { + const reply = this.accept(res) + if (reply) this.settle(reply) + }, + (error: Error) => this._pending.reject(payload.uuid, error), + ) + return response + } + + private async _createHttpClientRequest(data: RPCState): Promise { const options = { method: 'post', headers: { 'Content-Type': 'application/json; charset=UTF-8', }, - body: dataRaw, + body: stringify(data), } + // FiveM injects GetParentResourceName into NUI pages. Without it (page + // opened in a regular browser) the fetch fails and the call rejects. const resourceName = window?.GetParentResourceName?.() ?? 'nui-frame-app' return fetch( `https://${resourceName}/${RPCEvents.LISTENER_WEB}`, diff --git a/rpc/src/core/wrapper.ts b/rpc/src/core/wrapper.ts deleted file mode 100644 index 86f3061..0000000 --- a/rpc/src/core/wrapper.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Emitter } from '../utils/emitter' -import { parse } from '../utils/funcs' -import { - type RPCConfig, - type RPCEnvironment, - RPCErrors, - type RPCState, - type RPCStateRaw, -} from '../utils/types' - -export class Wrapper { - protected env: RPCEnvironment - protected _emitterLocal: Emitter - protected debug: boolean - protected console: Console - - constructor(cfg: RPCConfig) { - this.env = cfg.env - this._emitterLocal = new Emitter() - this.debug = cfg.debug ?? false - this.console = console - } - - protected verifyEvent(state: Emitter, data: RPCStateRaw | RPCState) { - const rpcData = typeof data === 'string' ? parse(data) : data - - if (!state.has(rpcData.event)) { - rpcData.error = RPCErrors.EVENT_NOT_REGISTERED - this.triggerError(rpcData) - } - } - - protected triggerError(rpcData: RPCState, error?: string): Error { - const errorMessage = [ - `${rpcData.error}`, - `Event: ${rpcData.event}`, - `Uuid: ${rpcData.uuid}`, - `From: ${rpcData.calledFrom}`, - `To: ${rpcData.calledTo}`, - `Player: ${rpcData.player}`, - `Type: ${rpcData.type}`, - `Data: ${rpcData.data}`, - ] - - if (error) { - errorMessage.push(`Info: ${error}`) - } - - throw new Error(errorMessage.join('\n | ')) - } -} diff --git a/rpc/src/fivem.d.ts b/rpc/src/fivem.d.ts new file mode 100644 index 0000000..716b21b --- /dev/null +++ b/rpc/src/fivem.d.ts @@ -0,0 +1,39 @@ +/* +FiveM runtime globals used by this library. Internal: not part of the published types, so consumers are free to use @citizenfx/*. +See https://docs.fivem.net/docs/scripting-reference/runtimes/javascript/ +*/ + +// ===== SHARED (client + server) ===== + +declare function on( + eventName: string, + callback: (...args: A) => unknown, +): void +declare function onNet( + eventName: string, + callback: (...args: A) => unknown, +): void +/** Server: `emitNet(eventName, target, ...args)` */ +declare function emitNet(eventName: string, ...args: unknown[]): void +declare function RegisterCommand( + commandName: string, + handler: (source: number, args: A, rawCommand: string) => unknown, + restricted: boolean, +): void + +// ===== SERVER ===== + +/** Player that sent the current net event. Only valid synchronously in the handler */ +declare var source: number + +// ===== CLIENT ===== + +declare function RegisterNuiCallbackType(callbackType: string): void +declare function SetNuiFocus(hasFocus: boolean, hasCursor: boolean): void +declare function SendNuiMessage(jsonString: string): boolean + +// ===== WEBVIEW (NUI) ===== + +interface Window { + GetParentResourceName?: () => string +} diff --git a/rpc/src/index.ts b/rpc/src/index.ts index a710e54..46612d3 100644 --- a/rpc/src/index.ts +++ b/rpc/src/index.ts @@ -1,7 +1,7 @@ import { RPCInstanceClient } from './core/client' import { RPCInstanceServer } from './core/server' import { RPCInstanceWebview } from './core/webview' -import { Wrapper } from './core/wrapper' +import { RPCError, unknownEnvironmentMessage } from './utils/errors' import { type RPCConfig, type RPCEnvironment, @@ -10,62 +10,60 @@ import { } from './utils/types' /** - * RPC Factory + * Creates the RPC instance for one environment. Create exactly one per + * environment (server, client, webview) and export it from a local module. + * Every client needs one, even without listeners: it relays calls between + * its webview and the server. + * + * @returns `RPCInstanceServer`, `RPCInstanceClient` or `RPCInstanceWebview`, + * matching `config.env` + * @throws {@link RPCError} `UNKNOWN_ENVIRONMENT` if `config.env` is not + * `'server'`, `'client'` or `'webview'` * * @example - * // returns RPCInstanceServer - * const rpc = new RPCFactory({ env: "server" }).get() - * - * @example - * // returns RPCInstanceClient - * const rpc = new RPCFactory({ env: "client" }).get() - * - * @example - * // returns RPCInstanceWebview - * const rpc = new RPCFactory({ env: "webview" }).get() - * - * @class + * // server/rpc.ts + * import { createRPC } from '@entityseven/fivem-rpc' + * export const rpc = createRPC({ env: 'server' }) */ -class RPCFactory extends Wrapper { - private readonly operator: - | RPCInstanceServer - | RPCInstanceClient - | RPCInstanceWebview - - /** - * Instance options - * @param {object} opts - Options - * @param {string} opts.env - Instance environment - * @param {boolean} opts.debug - Show additional logs - */ - constructor(opts: RPCConfig) { - super(opts) - - this.console.log('[RPC] Initializing...') - - switch (opts.env) { - case 'server': - this.operator = new RPCInstanceServer(opts as RPCConfig<'server'>) - break - case 'client': - this.operator = new RPCInstanceClient(opts as RPCConfig<'client'>) - break - case 'webview': - this.operator = new RPCInstanceWebview(opts as RPCConfig<'webview'>) - break - default: - throw new Error(RPCErrors.UNKNOWN_ENVIRONMENT) - } - } - - public get(): RPCEnvironmentResolved { - return this.operator as RPCEnvironmentResolved +export function createRPC( + config: RPCConfig, +): RPCEnvironmentResolved { + switch (config.env) { + case 'server': + return new RPCInstanceServer( + config as RPCConfig<'server'>, + ) as RPCEnvironmentResolved + case 'client': + return new RPCInstanceClient( + config as RPCConfig<'client'>, + ) as RPCEnvironmentResolved + case 'webview': + return new RPCInstanceWebview( + config as RPCConfig<'webview'>, + ) as RPCEnvironmentResolved + default: + throw new RPCError( + RPCErrors.UNKNOWN_ENVIRONMENT, + unknownEnvironmentMessage(config.env), + ) } } -export { RPCFactory } -export * from './utils/types' -export * from './utils/native' -export type * from './core/server' -export type * from './core/client' -export type * from './core/webview' +export type { RPCInstanceClient } from './core/client' +export type { RPCInstanceServer } from './core/server' +export type { RPCInstanceWebview } from './core/webview' +export { RPCError, type RPCErrorDetails } from './utils/errors' +export { + NATIVE_CLIENT_EVENTS, + NATIVE_CLIENT_NETWORK_EVENTS, + NATIVE_SERVER_EVENTS, +} from './utils/native' +export { + type RPCConfig, + type RPCEnvironment, + RPCErrors, + type RPCNativeClientEvents, + type RPCNativeClientNetworkEvents, + type RPCNativeClientNetworkEventsNames, + type RPCNativeServerEvents, +} from './utils/types' diff --git a/rpc/src/utils/emitter.ts b/rpc/src/utils/emitter.ts index 9adca71..e3c9623 100644 --- a/rpc/src/utils/emitter.ts +++ b/rpc/src/utils/emitter.ts @@ -1,23 +1,22 @@ import { RPCErrors } from './types' +/** + * Accepts any callback. Argument types are enforced by the typed `on*`/`emit*` + * methods that wrap the emitter, not here. + */ +export type Handler = (...args: never[]) => unknown + +/** One handler per event: registering an event again replaces its handler. */ export class Emitter { - /** Map */ - private _storage: Map any, boolean]> + /** Map */ + private _storage = new Map() - constructor() { - this._storage = new Map() - } - - get _raw_storage() { - return this._storage - } - - public on(event: string, cb: (...args: any[]) => any): this { + public on(event: string, cb: Handler): this { this._storage.set(event, [cb, false]) return this } - public once(event: string, cb: (...args: any[]) => any): this { + public once(event: string, cb: Handler): this { this._storage.set(event, [cb, true]) return this } @@ -31,24 +30,17 @@ export class Emitter { return this._storage.has(event) } - public async emit(event: string, ...args: any[]): Promise { - return new Promise((res, rej) => { - if (!this._storage.has(event)) { - rej(RPCErrors.EVENT_NOT_REGISTERED) - } + public async emit(event: string, ...args: unknown[]): Promise { + const entry = this._storage.get(event) + if (!entry) { + throw new Error(RPCErrors.EVENT_NOT_REGISTERED) + } - const [cb, once] = this._storage.get(event) as [ - (...args: any[]) => any, - boolean, - ] + const [cb, once] = entry + if (once) { + this._storage.delete(event) + } - if (once) { - this._storage.delete(event) - } - - Promise.resolve(cb(...args)) - .then(res) - .catch(rej) - }) + return (await cb(...(args as never[]))) as R } } diff --git a/rpc/src/utils/errors.ts b/rpc/src/utils/errors.ts new file mode 100644 index 0000000..73389f1 --- /dev/null +++ b/rpc/src/utils/errors.ts @@ -0,0 +1,100 @@ +import { type RPCEnvironment, RPCErrors, type RPCState } from './types' + +/** Where a failed call was going, when the error comes from a call */ +export type RPCErrorDetails = { + event: string + uuid: string + /** Environment that made the call */ + from: RPCEnvironment + /** Environment that was called */ + to: RPCEnvironment +} + +/** + * Error thrown or rejected by this library. Check `code` against `RPCErrors`. + * + * @example + * try { + * await rpc.emitServer('buyItem', 'water') + * } catch (e) { + * if (e instanceof RPCError && e.code === RPCErrors.TIMEOUT) { + * // server did not answer in time + * } + * } + */ +export class RPCError extends Error { + public readonly code: RPCErrors + public readonly details: RPCErrorDetails | undefined + + constructor(code: RPCErrors, message: string, details?: RPCErrorDetails) { + super(message) + this.name = 'RPCError' + this.code = code + this.details = details + } + + /** **Internal** Rebuilds the error a receiver sent back in a response */ + static fromResponse(response: RPCState): RPCError { + return new RPCError( + response.error?.code ?? RPCErrors.HANDLER_ERROR, + response.error?.message ?? 'Unknown error', + { + event: response.event, + uuid: response.uuid, + from: response.calledTo, + to: response.calledFrom, + }, + ) + } +} + +/** **Internal** Name of the method that listens on `receiver` for calls from `sender` */ +export function listenerName( + receiver: RPCEnvironment, + sender: RPCEnvironment, +): string { + if (receiver === sender) return 'onSelf' + return `on${sender[0]?.toUpperCase()}${sender.slice(1)}` +} + +/** **Internal** */ +export function notRegisteredMessage( + event: string, + receiver: RPCEnvironment, + sender: RPCEnvironment, +): string { + return `No listener for "${event}" on ${receiver}. Register it with rpc.${listenerName(receiver, sender)}("${event}", ...) in ${receiver} code.` +} + +/** **Internal** */ +export function unknownEnvironmentMessage(env: unknown): string { + return `Unknown env ${JSON.stringify(env)}. Use createRPC({ env: 'server' }), 'client' or 'webview'.` +} + +/** **Internal** */ +export function unknownNativeMessage(event: string, list: string): string { + return `"${event}" is not in ${list}. Events not listed there can be registered with FiveM's on("${event}", ...) directly.` +} + +/** **Internal** */ +export function handlerErrorMessage( + event: string, + receiver: RPCEnvironment, + cause: unknown, +): string { + const reason = + cause instanceof Error || (cause as Error | undefined)?.message + ? (cause as Error).message + : String(cause) + return `Listener for "${event}" on ${receiver} threw: ${reason}` +} + +/** **Internal** */ +export function timeoutMessage( + event: string, + receiver: RPCEnvironment, + sender: RPCEnvironment, + timeout: number, +): string { + return `No response for "${event}" from ${receiver} within ${timeout} ms. Check that ${receiver} registered rpc.${listenerName(receiver, sender)}("${event}", ...) and that it returns, or raise RPCConfig.timeout (0 disables it).` +} diff --git a/rpc/src/utils/funcs.ts b/rpc/src/utils/funcs.ts index 56c81c9..f67646d 100644 --- a/rpc/src/utils/funcs.ts +++ b/rpc/src/utils/funcs.ts @@ -1,17 +1,48 @@ import type { + RPCEnvironment, RPCState, RPCStateRaw, RPCStateWeb, RPCStateWebRaw, } from './types' +const ENVIRONMENTS: readonly unknown[] = [ + 'server', + 'client', + 'webview', +] satisfies RPCEnvironment[] + /** * **Internal** * - * Typed data parser + * Checks the shape of an incoming payload. Payloads come from the network or + * another runtime, so nothing about them is trusted. */ -export function parse(data: RPCStateRaw): RPCState { - return JSON.parse(data) +export function isRPCState(value: unknown): value is RPCState { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + return ( + typeof v.event === 'string' && + typeof v.uuid === 'string' && + (v.type === 'event' || v.type === 'response' || v.type === 'broadcast') && + ENVIRONMENTS.includes(v.calledFrom) && + ENVIRONMENTS.includes(v.calledTo) && + (v.data === null || Array.isArray(v.data)) + ) +} + +/** + * **Internal** + * + * Parses a raw payload, `null` if it is not JSON or not an RPC payload + */ +export function parse(data: RPCStateRaw): RPCState | null { + try { + const value: unknown = JSON.parse(data) + return isRPCState(value) ? value : null + } catch { + return null + } } /** @@ -23,21 +54,24 @@ export function stringify(data: RPCState): RPCStateRaw { return JSON.stringify(data) as RPCStateRaw } -// automatically parsed by FiveM -// export function parseWeb(data: RPCStateWebRaw): RPCStateWeb { -// return JSON.parse(data) -// } - /** * **Internal** * - * Typed data serializer + * Typed data serializer. No parse counterpart: the webview receives NUI + * messages already parsed by FiveM. */ export function stringifyWeb(data: RPCStateWeb): RPCStateWebRaw { return JSON.stringify(data) as RPCStateWebRaw } -/** **Internal** */ +/** + * **Internal** + * + * UUID v4 shaped call id. Not `crypto.randomUUID`: the FiveM client runtime + * has no `crypto` global, and one generator serves all environments. Only + * pairs a response with its call, so it need not be unguessable: the server + * binds each call to its target player (`Pending` peer check). + */ export function generateUUID(): string { let uuid = '' let random = 0 diff --git a/rpc/src/utils/native.ts b/rpc/src/utils/native.ts index ab739c9..61dd821 100644 --- a/rpc/src/utils/native.ts +++ b/rpc/src/utils/native.ts @@ -1,321 +1,329 @@ -import type { - RPCNativeClientEvents, - RPCNativeClientNetworkEventsNames, - RPCNativeServerEvents, -} from './types' +import type { RPCNativeClientEvents, RPCNativeServerEvents } from './types' + +// Server and client lists are built from an object that must name exactly the +// keys of the typed event map, so the list and the types cannot drift apart. + +const serverEvents = { + entityCreated: true, + entityCreating: true, + entityRemoved: true, + onResourceListRefresh: true, + onResourceStart: true, + onResourceStarting: true, + onResourceStop: true, + onServerResourceStart: true, + onServerResourceStop: true, + playerConnecting: true, + playerEnteredScope: true, + playerJoining: true, + playerLeftScope: true, + ptFxEvent: true, + removeAllWeaponsEvent: true, + startProjectileEvent: true, + weaponDamageEvent: true, +} satisfies Record /** + * Events `onNativeEvent` accepts on the server: * https://docs.fivem.net/docs/scripting-reference/events/server-events/ - * @readonly */ -export const NATIVE_SERVER_EVENTS: readonly (keyof RPCNativeServerEvents)[] = [ - 'entityCreated', - 'entityCreating', - 'entityRemoved', - 'onResourceListRefresh', - 'onResourceStart', - 'onResourceStarting', - 'onResourceStop', - 'onServerResourceStart', - 'onServerResourceStop', - 'playerConnecting', - 'playerEnteredScope', - 'playerJoining', - 'playerLeftScope', - 'ptFxEvent', - 'removeAllWeaponsEvent', - 'startProjectileEvent', - 'weaponDamageEvent', -] as const +export const NATIVE_SERVER_EVENTS = Object.keys( + serverEvents, +) as readonly (keyof RPCNativeServerEvents)[] + +const clientEvents = { + entityDamaged: true, + gameEventTriggered: true, + mumbleConnected: true, + mumbleDisconnected: true, + onClientResourceStart: true, + onClientResourceStop: true, + onResourceStart: true, + onResourceStarting: true, + onResourceStop: true, + populationPedCreating: true, +} satisfies Record /** + * Events `onNativeEvent` accepts on the client: * https://docs.fivem.net/docs/scripting-reference/events/client-events/ - * @readonly */ -export const NATIVE_CLIENT_EVENTS: readonly (keyof RPCNativeClientEvents)[] = [ - 'entityDamaged', - 'gameEventTriggered', - 'mumbleConnected', - 'mumbleDisconnected', - 'onClientResourceStart', - 'onClientResourceStop', - 'onResourceStart', - 'onResourceStarting', - 'onResourceStop', - 'populationPedCreating', -] as const +export const NATIVE_CLIENT_EVENTS = Object.keys( + clientEvents, +) as readonly (keyof RPCNativeClientEvents)[] /** + * Events `onNativeNetworkEvent` accepts on the client: * https://docs.fivem.net/docs/game-references/game-events/ - * @readonly + * + * Source of truth for `RPCNativeClientNetworkEventsNames`. */ -export const NATIVE_CLIENT_NETWORK_EVENTS: readonly RPCNativeClientNetworkEventsNames[] = - [ - 'CEventAcquaintancePed', - 'CEventAcquaintancePedDead', - 'CEventAcquaintancePedDislike', - 'CEventAcquaintancePedHate', - 'CEventAcquaintancePedLike', - 'CEventAcquaintancePedWanted', - 'CEventAgitated', - 'CEventAgitatedAction', - 'CEventCallForCover', - 'CEventCarUndriveable', - 'CEventClimbLadderOnRoute', - 'CEventClimbNavMeshOnRoute', - 'CEventCombatTaunt', - 'CEventCommunicateEvent', - 'CEventCopCarBeingStolen', - 'CEventCrimeCryForHelp', - 'CEventCrimeReported', - 'CEventDamage', - 'CEventDataDecisionMaker', - 'CEventDataFileMounter', - 'CEventDataResponseAggressiveRubberneck', - 'CEventDataResponseDeferToScenarioPointFlags', - 'CEventDataResponseFriendlyAimedAt', - 'CEventDataResponseFriendlyNearMiss', - 'CEventDataResponsePlayerDeath', - 'CEventDataResponsePoliceTaskWanted', - 'CEventDataResponseSwatTaskWanted', - 'CEventDataResponseTask', - 'CEventDataResponseTaskAgitated', - 'CEventDataResponseTaskCombat', - 'CEventDataResponseTaskCower', - 'CEventDataResponseTaskCrouch', - 'CEventDataResponseTaskDuckAndCover', - 'CEventDataResponseTaskEscapeBlast', - 'CEventDataResponseTaskEvasiveStep', - 'CEventDataResponseTaskExhaustedFlee', - 'CEventDataResponseTaskExplosion', - 'CEventDataResponseTaskFlee', - 'CEventDataResponseTaskFlyAway', - 'CEventDataResponseTaskGrowlAndFlee', - 'CEventDataResponseTaskGunAimedAt', - 'CEventDataResponseTaskHandsUp', - 'CEventDataResponseTaskHeadTrack', - 'CEventDataResponseTaskLeaveCarAndFlee', - 'CEventDataResponseTaskScenarioFlee', - 'CEventDataResponseTaskSharkAttack', - 'CEventDataResponseTaskShockingEventBackAway', - 'CEventDataResponseTaskShockingEventGoto', - 'CEventDataResponseTaskShockingEventHurryAway', - 'CEventDataResponseTaskShockingEventReact', - 'CEventDataResponseTaskShockingEventReactToAircraft', - 'CEventDataResponseTaskShockingEventStopAndStare', - 'CEventDataResponseTaskShockingEventThreatResponse', - 'CEventDataResponseTaskShockingEventWatch', - 'CEventDataResponseTaskShockingNiceCar', - 'CEventDataResponseTaskShockingPoliceInvestigate', - 'CEventDataResponseTaskThreat', - 'CEventDataResponseTaskTurnToFace', - 'CEventDataResponseTaskWalkAway', - 'CEventDataResponseTaskWalkRoundEntity', - 'CEventDataResponseTaskWalkRoundFire', - 'CEventDeadPedFound', - 'CEventDeath', - 'CEventDecisionMakerResponse', - 'CEventDisturbance', - 'CEventDraggedOutCar', - 'CEventEditableResponse', - 'CEventEncroachingPed', - 'CEventEntityDamaged', - 'CEventEntityDestroyed', - 'CEventExplosion', - 'CEventExplosionHeard', - 'CEventFireNearby', - 'CEventFootStepHeard', - 'CEventFriendlyAimedAt', - 'CEventFriendlyFireNearMiss', - 'CEventGetOutOfWater', - 'CEventGivePedTask', - 'CEventGroupScriptAI', - 'CEventGroupScriptNetwork', - 'CEventGunAimedAt', - 'CEventGunShot', - 'CEventGunShotBulletImpact', - 'CEventGunShotWhizzedBy', - 'CEventHelpAmbientFriend', - 'CEventHurtTransition', - 'CEventInAir', - 'CEventInfo', - 'CEventInfoBase', - 'CEventInjuredCryForHelp', - 'CEventLeaderEnteredCarAsDriver', - 'CEventLeaderExitedCarAsDriver', - 'CEventLeaderHolsteredWeapon', - 'CEventLeaderLeftCover', - 'CEventLeaderUnholsteredWeapon', - 'CEventMeleeAction', - 'CEventMustLeaveBoat', - 'CEventNetworkAdminInvited', - 'CEventNetworkAttemptHostMigration', - 'CEventNetworkBail', - 'CEventNetworkCashTransactionLog', - 'CEventNetworkCheatTriggered', - 'CEventNetworkClanInviteReceived', - 'CEventNetworkClanJoined', - 'CEventNetworkClanKicked', - 'CEventNetworkClanLeft', - 'CEventNetworkClanRankChanged', - 'CEventNetworkCloudEvent', - 'CEventNetworkCloudFileResponse', - 'CEventNetworkEmailReceivedEvent', - 'CEventNetworkEndMatch', - 'CEventNetworkEndSession', - 'CEventNetworkEntityDamage', - 'CEventNetworkFindSession', - 'CEventNetworkFollowInviteReceived', - 'CEventNetworkHostMigration', - 'CEventNetworkHostSession', - 'CEventNetworkIncrementStat', - 'CEventNetworkInviteAccepted', - 'CEventNetworkInviteConfirmed', - 'CEventNetworkInviteRejected', - 'CEventNetworkJoinSession', - 'CEventNetworkJoinSessionResponse', - 'CEventNetworkOnlinePermissionsUpdated', - 'CEventNetworkPedLeftBehind', - 'CEventNetworkPickupRespawned', - 'CEventNetworkPlayerArrest', - 'CEventNetworkPlayerCollectedAmbientPickup', - 'CEventNetworkPlayerCollectedPickup', - 'CEventNetworkPlayerCollectedPortablePickup', - 'CEventNetworkPlayerDroppedPortablePickup', - 'CEventNetworkPlayerEnteredVehicle', - 'CEventNetworkPlayerJoinScript', - 'CEventNetworkPlayerLeftScript', - 'CEventNetworkPlayerScript', - 'CEventNetworkPlayerSession', - 'CEventNetworkPlayerSpawn', - 'CEventNetworkPresenceInvite', - 'CEventNetworkPresenceInviteRemoved', - 'CEventNetworkPresenceInviteReply', - 'CEventNetworkPresenceTriggerEvent', - 'CEventNetworkPresence_StatUpdate', - 'CEventNetworkPrimaryClanChanged', - 'CEventNetworkRequestDelay', - 'CEventNetworkRosChanged', - 'CEventNetworkScAdminPlayerUpdated', - 'CEventNetworkScAdminReceivedCash', - 'CEventNetworkScriptEvent', - 'CEventNetworkSessionEvent', - 'CEventNetworkShopTransaction', - 'CEventNetworkSignInStateChanged', - 'CEventNetworkSocialClubAccountLinked', - 'CEventNetworkSpectateLocal', - 'CEventNetworkStartMatch', - 'CEventNetworkStartSession', - 'CEventNetworkStorePlayerLeft', - 'CEventNetworkSummon', - 'CEventNetworkSystemServiceEvent', - 'CEventNetworkTextMessageReceived', - 'CEventNetworkTimedExplosion', - 'CEventNetworkTransitionEvent', - 'CEventNetworkTransitionGamerInstruction', - 'CEventNetworkTransitionMemberJoined', - 'CEventNetworkTransitionMemberLeft', - 'CEventNetworkTransitionParameterChanged', - 'CEventNetworkTransitionStarted', - 'CEventNetworkTransitionStringChanged', - 'CEventNetworkVehicleUndrivable', - 'CEventNetworkVoiceConnectionRequested', - 'CEventNetworkVoiceConnectionResponse', - 'CEventNetworkVoiceConnectionTerminated', - 'CEventNetworkVoiceSessionEnded', - 'CEventNetworkVoiceSessionStarted', - 'CEventNetworkWithData', - 'CEventNetwork_InboxMsgReceived', - 'CEventNewTask', - 'CEventObjectCollision', - 'CEventOnFire', - 'CEventOpenDoor', - 'CEventPedCollisionWithPed', - 'CEventPedCollisionWithPlayer', - 'CEventPedEnteredMyVehicle', - 'CEventPedJackingMyVehicle', - 'CEventPedOnCarRoof', - 'CEventPedSeenDeadPed', - 'CEventPlayerCollisionWithPed', - 'CEventPlayerDeath', - 'CEventPlayerUnableToEnterVehicle', - 'CEventPotentialBeWalkedInto', - 'CEventPotentialBlast', - 'CEventPotentialGetRunOver', - 'CEventPotentialWalkIntoVehicle', - 'CEventProvidingCover', - 'CEventRanOverPed', - 'CEventReactionEnemyPed', - 'CEventReactionInvestigateDeadPed', - 'CEventReactionInvestigateThreat', - 'CEventRequestHelp', - 'CEventRequestHelpWithConfrontation', - 'CEventRespondedToThreat', - 'CEventScanner', - 'CEventScenarioForceAction', - 'CEventScriptCommand', - 'CEventScriptWithData', - 'CEventShocking', - 'CEventShockingBicycleCrash', - 'CEventShockingBicycleOnPavement', - 'CEventShockingCarAlarm', - 'CEventShockingCarChase', - 'CEventShockingCarCrash', - 'CEventShockingCarOnCar', - 'CEventShockingCarPileUp', - 'CEventShockingDangerousAnimal', - 'CEventShockingDeadBody', - 'CEventShockingDrivingOnPavement', - 'CEventShockingEngineRevved', - 'CEventShockingExplosion', - 'CEventShockingFire', - 'CEventShockingGunFight', - 'CEventShockingGunshotFired', - 'CEventShockingHelicopterOverhead', - 'CEventShockingHornSounded', - 'CEventShockingInDangerousVehicle', - 'CEventShockingInjuredPed', - 'CEventShockingMadDriver', - 'CEventShockingMadDriverBicycle', - 'CEventShockingMadDriverExtreme', - 'CEventShockingMugging', - 'CEventShockingNonViolentWeaponAimedAt', - 'CEventShockingParachuterOverhead', - 'CEventShockingPedKnockedIntoByPlayer', - 'CEventShockingPedRunOver', - 'CEventShockingPedShot', - 'CEventShockingPlaneFlyby', - 'CEventShockingPotentialBlast', - 'CEventShockingPropertyDamage', - 'CEventShockingRunningPed', - 'CEventShockingRunningStampede', - 'CEventShockingSeenCarStolen', - 'CEventShockingSeenConfrontation', - 'CEventShockingSeenGangFight', - 'CEventShockingSeenInsult', - 'CEventShockingSeenMeleeAction', - 'CEventShockingSeenNiceCar', - 'CEventShockingSeenPedKilled', - 'CEventShockingSiren', - 'CEventShockingStudioBomb', - 'CEventShockingVehicleTowed', - 'CEventShockingVisibleWeapon', - 'CEventShockingWeaponThreat', - 'CEventShockingWeirdPed', - 'CEventShockingWeirdPedApproaching', - 'CEventShoutBlockingLos', - 'CEventShoutTargetPosition', - 'CEventShovePed', - 'CEventSoundBase', - 'CEventStatChangedValue', - 'CEventStaticCountReachedMax', - 'CEventStuckInAir', - 'CEventSuspiciousActivity', - 'CEventSwitch2NM', - 'CEventUnidentifiedPed', - 'CEventVehicleCollision', - 'CEventVehicleDamage', - 'CEventVehicleDamageWeapon', - 'CEventVehicleOnFire', - 'CEventWrithe', - ] as const +export const NATIVE_CLIENT_NETWORK_EVENTS = [ + 'CEventAcquaintancePed', + 'CEventAcquaintancePedDead', + 'CEventAcquaintancePedDislike', + 'CEventAcquaintancePedHate', + 'CEventAcquaintancePedLike', + 'CEventAcquaintancePedWanted', + 'CEventAgitated', + 'CEventAgitatedAction', + 'CEventCallForCover', + 'CEventCarUndriveable', + 'CEventClimbLadderOnRoute', + 'CEventClimbNavMeshOnRoute', + 'CEventCombatTaunt', + 'CEventCommunicateEvent', + 'CEventCopCarBeingStolen', + 'CEventCrimeCryForHelp', + 'CEventCrimeReported', + 'CEventDamage', + 'CEventDataDecisionMaker', + 'CEventDataFileMounter', + 'CEventDataResponseAggressiveRubberneck', + 'CEventDataResponseDeferToScenarioPointFlags', + 'CEventDataResponseFriendlyAimedAt', + 'CEventDataResponseFriendlyNearMiss', + 'CEventDataResponsePlayerDeath', + 'CEventDataResponsePoliceTaskWanted', + 'CEventDataResponseSwatTaskWanted', + 'CEventDataResponseTask', + 'CEventDataResponseTaskAgitated', + 'CEventDataResponseTaskCombat', + 'CEventDataResponseTaskCower', + 'CEventDataResponseTaskCrouch', + 'CEventDataResponseTaskDuckAndCover', + 'CEventDataResponseTaskEscapeBlast', + 'CEventDataResponseTaskEvasiveStep', + 'CEventDataResponseTaskExhaustedFlee', + 'CEventDataResponseTaskExplosion', + 'CEventDataResponseTaskFlee', + 'CEventDataResponseTaskFlyAway', + 'CEventDataResponseTaskGrowlAndFlee', + 'CEventDataResponseTaskGunAimedAt', + 'CEventDataResponseTaskHandsUp', + 'CEventDataResponseTaskHeadTrack', + 'CEventDataResponseTaskLeaveCarAndFlee', + 'CEventDataResponseTaskScenarioFlee', + 'CEventDataResponseTaskSharkAttack', + 'CEventDataResponseTaskShockingEventBackAway', + 'CEventDataResponseTaskShockingEventGoto', + 'CEventDataResponseTaskShockingEventHurryAway', + 'CEventDataResponseTaskShockingEventReact', + 'CEventDataResponseTaskShockingEventReactToAircraft', + 'CEventDataResponseTaskShockingEventStopAndStare', + 'CEventDataResponseTaskShockingEventThreatResponse', + 'CEventDataResponseTaskShockingEventWatch', + 'CEventDataResponseTaskShockingNiceCar', + 'CEventDataResponseTaskShockingPoliceInvestigate', + 'CEventDataResponseTaskThreat', + 'CEventDataResponseTaskTurnToFace', + 'CEventDataResponseTaskWalkAway', + 'CEventDataResponseTaskWalkRoundEntity', + 'CEventDataResponseTaskWalkRoundFire', + 'CEventDeadPedFound', + 'CEventDeath', + 'CEventDecisionMakerResponse', + 'CEventDisturbance', + 'CEventDraggedOutCar', + 'CEventEditableResponse', + 'CEventEncroachingPed', + 'CEventEntityDamaged', + 'CEventEntityDestroyed', + 'CEventExplosion', + 'CEventExplosionHeard', + 'CEventFireNearby', + 'CEventFootStepHeard', + 'CEventFriendlyAimedAt', + 'CEventFriendlyFireNearMiss', + 'CEventGetOutOfWater', + 'CEventGivePedTask', + 'CEventGroupScriptAI', + 'CEventGroupScriptNetwork', + 'CEventGunAimedAt', + 'CEventGunShot', + 'CEventGunShotBulletImpact', + 'CEventGunShotWhizzedBy', + 'CEventHelpAmbientFriend', + 'CEventHurtTransition', + 'CEventInAir', + 'CEventInfo', + 'CEventInfoBase', + 'CEventInjuredCryForHelp', + 'CEventLeaderEnteredCarAsDriver', + 'CEventLeaderExitedCarAsDriver', + 'CEventLeaderHolsteredWeapon', + 'CEventLeaderLeftCover', + 'CEventLeaderUnholsteredWeapon', + 'CEventMeleeAction', + 'CEventMustLeaveBoat', + 'CEventNetworkAdminInvited', + 'CEventNetworkAttemptHostMigration', + 'CEventNetworkBail', + 'CEventNetworkCashTransactionLog', + 'CEventNetworkCheatTriggered', + 'CEventNetworkClanInviteReceived', + 'CEventNetworkClanJoined', + 'CEventNetworkClanKicked', + 'CEventNetworkClanLeft', + 'CEventNetworkClanRankChanged', + 'CEventNetworkCloudEvent', + 'CEventNetworkCloudFileResponse', + 'CEventNetworkEmailReceivedEvent', + 'CEventNetworkEndMatch', + 'CEventNetworkEndSession', + 'CEventNetworkEntityDamage', + 'CEventNetworkFindSession', + 'CEventNetworkFollowInviteReceived', + 'CEventNetworkHostMigration', + 'CEventNetworkHostSession', + 'CEventNetworkIncrementStat', + 'CEventNetworkInviteAccepted', + 'CEventNetworkInviteConfirmed', + 'CEventNetworkInviteRejected', + 'CEventNetworkJoinSession', + 'CEventNetworkJoinSessionResponse', + 'CEventNetworkOnlinePermissionsUpdated', + 'CEventNetworkPedLeftBehind', + 'CEventNetworkPickupRespawned', + 'CEventNetworkPlayerArrest', + 'CEventNetworkPlayerCollectedAmbientPickup', + 'CEventNetworkPlayerCollectedPickup', + 'CEventNetworkPlayerCollectedPortablePickup', + 'CEventNetworkPlayerDroppedPortablePickup', + 'CEventNetworkPlayerEnteredVehicle', + 'CEventNetworkPlayerJoinScript', + 'CEventNetworkPlayerLeftScript', + 'CEventNetworkPlayerScript', + 'CEventNetworkPlayerSession', + 'CEventNetworkPlayerSpawn', + 'CEventNetworkPresenceInvite', + 'CEventNetworkPresenceInviteRemoved', + 'CEventNetworkPresenceInviteReply', + 'CEventNetworkPresenceTriggerEvent', + 'CEventNetworkPresence_StatUpdate', + 'CEventNetworkPrimaryClanChanged', + 'CEventNetworkRequestDelay', + 'CEventNetworkRosChanged', + 'CEventNetworkScAdminPlayerUpdated', + 'CEventNetworkScAdminReceivedCash', + 'CEventNetworkScriptEvent', + 'CEventNetworkSessionEvent', + 'CEventNetworkShopTransaction', + 'CEventNetworkSignInStateChanged', + 'CEventNetworkSocialClubAccountLinked', + 'CEventNetworkSpectateLocal', + 'CEventNetworkStartMatch', + 'CEventNetworkStartSession', + 'CEventNetworkStorePlayerLeft', + 'CEventNetworkSummon', + 'CEventNetworkSystemServiceEvent', + 'CEventNetworkTextMessageReceived', + 'CEventNetworkTimedExplosion', + 'CEventNetworkTransitionEvent', + 'CEventNetworkTransitionGamerInstruction', + 'CEventNetworkTransitionMemberJoined', + 'CEventNetworkTransitionMemberLeft', + 'CEventNetworkTransitionParameterChanged', + 'CEventNetworkTransitionStarted', + 'CEventNetworkTransitionStringChanged', + 'CEventNetworkVehicleUndrivable', + 'CEventNetworkVoiceConnectionRequested', + 'CEventNetworkVoiceConnectionResponse', + 'CEventNetworkVoiceConnectionTerminated', + 'CEventNetworkVoiceSessionEnded', + 'CEventNetworkVoiceSessionStarted', + 'CEventNetworkWithData', + 'CEventNetwork_InboxMsgReceived', + 'CEventNewTask', + 'CEventObjectCollision', + 'CEventOnFire', + 'CEventOpenDoor', + 'CEventPedCollisionWithPed', + 'CEventPedCollisionWithPlayer', + 'CEventPedEnteredMyVehicle', + 'CEventPedJackingMyVehicle', + 'CEventPedOnCarRoof', + 'CEventPedSeenDeadPed', + 'CEventPlayerCollisionWithPed', + 'CEventPlayerDeath', + 'CEventPlayerUnableToEnterVehicle', + 'CEventPotentialBeWalkedInto', + 'CEventPotentialBlast', + 'CEventPotentialGetRunOver', + 'CEventPotentialWalkIntoVehicle', + 'CEventProvidingCover', + 'CEventRanOverPed', + 'CEventReactionEnemyPed', + 'CEventReactionInvestigateDeadPed', + 'CEventReactionInvestigateThreat', + 'CEventRequestHelp', + 'CEventRequestHelpWithConfrontation', + 'CEventRespondedToThreat', + 'CEventScanner', + 'CEventScenarioForceAction', + 'CEventScriptCommand', + 'CEventScriptWithData', + 'CEventShocking', + 'CEventShockingBicycleCrash', + 'CEventShockingBicycleOnPavement', + 'CEventShockingCarAlarm', + 'CEventShockingCarChase', + 'CEventShockingCarCrash', + 'CEventShockingCarOnCar', + 'CEventShockingCarPileUp', + 'CEventShockingDangerousAnimal', + 'CEventShockingDeadBody', + 'CEventShockingDrivingOnPavement', + 'CEventShockingEngineRevved', + 'CEventShockingExplosion', + 'CEventShockingFire', + 'CEventShockingGunFight', + 'CEventShockingGunshotFired', + 'CEventShockingHelicopterOverhead', + 'CEventShockingHornSounded', + 'CEventShockingInDangerousVehicle', + 'CEventShockingInjuredPed', + 'CEventShockingMadDriver', + 'CEventShockingMadDriverBicycle', + 'CEventShockingMadDriverExtreme', + 'CEventShockingMugging', + 'CEventShockingNonViolentWeaponAimedAt', + 'CEventShockingParachuterOverhead', + 'CEventShockingPedKnockedIntoByPlayer', + 'CEventShockingPedRunOver', + 'CEventShockingPedShot', + 'CEventShockingPlaneFlyby', + 'CEventShockingPotentialBlast', + 'CEventShockingPropertyDamage', + 'CEventShockingRunningPed', + 'CEventShockingRunningStampede', + 'CEventShockingSeenCarStolen', + 'CEventShockingSeenConfrontation', + 'CEventShockingSeenGangFight', + 'CEventShockingSeenInsult', + 'CEventShockingSeenMeleeAction', + 'CEventShockingSeenNiceCar', + 'CEventShockingSeenPedKilled', + 'CEventShockingSiren', + 'CEventShockingStudioBomb', + 'CEventShockingVehicleTowed', + 'CEventShockingVisibleWeapon', + 'CEventShockingWeaponThreat', + 'CEventShockingWeirdPed', + 'CEventShockingWeirdPedApproaching', + 'CEventShoutBlockingLos', + 'CEventShoutTargetPosition', + 'CEventShovePed', + 'CEventSoundBase', + 'CEventStatChangedValue', + 'CEventStaticCountReachedMax', + 'CEventStuckInAir', + 'CEventSuspiciousActivity', + 'CEventSwitch2NM', + 'CEventUnidentifiedPed', + 'CEventVehicleCollision', + 'CEventVehicleDamage', + 'CEventVehicleDamageWeapon', + 'CEventVehicleOnFire', + 'CEventWrithe', +] as const diff --git a/rpc/src/utils/pending.ts b/rpc/src/utils/pending.ts new file mode 100644 index 0000000..c2f419b --- /dev/null +++ b/rpc/src/utils/pending.ts @@ -0,0 +1,84 @@ +import { RPCError, timeoutMessage } from './errors' +import { RPCErrors, type RPCState } from './types' + +type Call = { + resolve: (data: unknown) => void + reject: (error: Error) => void + timer: ReturnType | undefined + /** Only a response from this peer settles the call (server: target player) */ + peer: number | undefined +} + +/** Calls waiting for a response, keyed by payload uuid. */ +export class Pending { + private _calls = new Map() + + /** @param timeout - ms before a call rejects, `0` or less disables it */ + constructor(private readonly _timeout: number) {} + + /** + * Waits for the response to `request`, rejects with `RPCErrors.TIMEOUT`. + * + * @param peer - if set, only a response from this peer settles the call + */ + public wait(request: RPCState, peer?: number): Promise { + return new Promise((resolve, reject) => { + const timer = + this._timeout > 0 + ? setTimeout( + () => + this.reject(request.uuid, this._timeoutError(request), peer), + this._timeout, + ) + : undefined + + this._calls.set(request.uuid, { + resolve: resolve as (data: unknown) => void, + reject, + timer, + peer, + }) + }) + } + + /** @returns `false` if no call from `peer` waits for this uuid (late or unexpected response) */ + public resolve(uuid: string, data: unknown, peer?: number): boolean { + const call = this._take(uuid, peer) + call?.resolve(data) + return call !== undefined + } + + /** @returns `false` if no call from `peer` waits for this uuid */ + public reject(uuid: string, error: Error, peer?: number): boolean { + const call = this._take(uuid, peer) + call?.reject(error) + return call !== undefined + } + + private _take(uuid: string, peer: number | undefined): Call | undefined { + const call = this._calls.get(uuid) + if (!call || (call.peer !== undefined && call.peer !== peer)) return + + this._calls.delete(uuid) + clearTimeout(call.timer) + return call + } + + private _timeoutError(request: RPCState): RPCError { + return new RPCError( + RPCErrors.TIMEOUT, + timeoutMessage( + request.event, + request.calledTo, + request.calledFrom, + this._timeout, + ), + { + event: request.event, + uuid: request.uuid, + from: request.calledFrom, + to: request.calledTo, + }, + ) + } +} diff --git a/rpc/src/utils/types.ts b/rpc/src/utils/types.ts index e08ad43..b7b5d1d 100644 --- a/rpc/src/utils/types.ts +++ b/rpc/src/utils/types.ts @@ -1,9 +1,11 @@ import type { RPCInstanceClient } from '../core/client' import type { RPCInstanceServer } from '../core/server' import type { RPCInstanceWebview } from '../core/webview' +import type { NATIVE_CLIENT_NETWORK_EVENTS } from './native' /** - * Possible environment states for `RPCConfig` + * Where an instance runs: `server` (server scripts), `client` (client + * scripts) or `webview` (NUI page) */ export type RPCEnvironment = 'server' | 'client' | 'webview' @@ -16,18 +18,33 @@ export type RPCEnvironmentResolved = ? RPCInstanceWebview : never -/** - * `RPCFactory` config. - * - * If environment does not match will throw `RPCErrors.UNKNOWN_ENVIRONMENT` - */ -export type RPCConfig = { +/** `createRPC` config */ +export type RPCConfig = { + /** Environment this instance runs in */ env: T + /** + * Log every registration, call and incoming payload + * + * @defaultValue false + */ debug?: boolean + /** + * Milliseconds to wait for a response before the call rejects with + * `RPCErrors.TIMEOUT`. `0` disables the timeout. + * + * @defaultValue 5000 + */ + timeout?: number } -/** **Internal** */ -export type RPCEventType = 'event' | 'response' +/** + * **Internal** + * + * - `event`: call that expects a `response` + * - `response`: answer to an `event` + * - `broadcast`: one-way event, receivers do not reply + */ +export type RPCEventType = 'event' | 'response' | 'broadcast' /** * **Internal** @@ -39,12 +56,19 @@ export type RPCState = { uuid: string calledFrom: RPCEnvironment calledTo: RPCEnvironment - error: string | null + error: RPCErrorPayload | null data: unknown[] | null + /** Server id of the player involved. The server fills it from `source`, never trusting the sender */ player: number | null type: RPCEventType } +/** **Internal** Error sent back in a response, rebuilt as `RPCError` by the caller */ +export type RPCErrorPayload = { + code: RPCErrors + message: string +} + /** * **Internal** * @@ -76,18 +100,22 @@ export enum RPCEvents { LISTENER_WEB = '__rpc:listenerWeb', } -/** - * Errors to check against - */ +/** Values of `RPCError.code` */ export enum RPCErrors { + /** The target has no listener for the event (or `emitSelf` has no `onSelf`) */ EVENT_NOT_REGISTERED = 'Event not registered', - INVALID_DATA = 'Invalid data (possibly broken JSON)', - NO_PLAYER = 'No player (failed to resolve from local index)', - UNKNOWN_NATIVE = 'Unknown native event (if you are sure this exists - use native handler)', - UNKNOWN_ENVIRONMENT = 'Unknown environment (must be either "server", "client" or "webview")', + /** `onNative*` got an event that is not in its `NATIVE_*` list */ + UNKNOWN_NATIVE = 'Unknown native event', + /** `createRPC` got an `env` other than server, client or webview */ + UNKNOWN_ENVIRONMENT = 'Unknown environment', + /** No response within `RPCConfig.timeout` */ + TIMEOUT = 'Timed out waiting for response', + /** The listener on the target threw; the message carries its error */ + HANDLER_ERROR = 'Listener threw an error', } /** + * Native server events accepted by `onNativeEvent` on the server: * https://docs.fivem.net/docs/scripting-reference/events/server-events/ */ export type RPCNativeServerEvents = { @@ -222,6 +250,7 @@ export type RPCNativeServerEvents = { } /** + * Native client events accepted by `onNativeEvent` on the client: * https://docs.fivem.net/docs/scripting-reference/events/client-events/ */ export type RPCNativeClientEvents = { @@ -232,7 +261,7 @@ export type RPCNativeClientEvents = { baseDamage: number, ): void gameEventTriggered( - name: RPCNativeClientNetworksEvents | string, + name: RPCNativeClientNetworkEventsNames | (string & {}), data: number[], ): void mumbleConnected(address: string, reconnecting: boolean): void @@ -254,7 +283,8 @@ export type RPCNativeClientEvents = { ): void } -export type RPCNativeClientNetworksEvents = { +/** Game events accepted by `onNativeNetworkEvent` on the client */ +export type RPCNativeClientNetworkEvents = { [name in RPCNativeClientNetworkEventsNames]: ( entities: number[], eventEntity: number, @@ -263,273 +293,8 @@ export type RPCNativeClientNetworksEvents = { } /** + * Names in `NATIVE_CLIENT_NETWORK_EVENTS`: * https://docs.fivem.net/docs/game-references/game-events/ */ export type RPCNativeClientNetworkEventsNames = - | 'CEventAcquaintancePed' - | 'CEventAcquaintancePedDead' - | 'CEventAcquaintancePedDislike' - | 'CEventAcquaintancePedHate' - | 'CEventAcquaintancePedLike' - | 'CEventAcquaintancePedWanted' - | 'CEventAgitated' - | 'CEventAgitatedAction' - | 'CEventCallForCover' - | 'CEventCarUndriveable' - | 'CEventClimbLadderOnRoute' - | 'CEventClimbNavMeshOnRoute' - | 'CEventCombatTaunt' - | 'CEventCommunicateEvent' - | 'CEventCopCarBeingStolen' - | 'CEventCrimeCryForHelp' - | 'CEventCrimeReported' - | 'CEventDamage' - | 'CEventDataDecisionMaker' - | 'CEventDataFileMounter' - | 'CEventDataResponseAggressiveRubberneck' - | 'CEventDataResponseDeferToScenarioPointFlags' - | 'CEventDataResponseFriendlyAimedAt' - | 'CEventDataResponseFriendlyNearMiss' - | 'CEventDataResponsePlayerDeath' - | 'CEventDataResponsePoliceTaskWanted' - | 'CEventDataResponseSwatTaskWanted' - | 'CEventDataResponseTask' - | 'CEventDataResponseTaskAgitated' - | 'CEventDataResponseTaskCombat' - | 'CEventDataResponseTaskCower' - | 'CEventDataResponseTaskCrouch' - | 'CEventDataResponseTaskDuckAndCover' - | 'CEventDataResponseTaskEscapeBlast' - | 'CEventDataResponseTaskEvasiveStep' - | 'CEventDataResponseTaskExhaustedFlee' - | 'CEventDataResponseTaskExplosion' - | 'CEventDataResponseTaskFlee' - | 'CEventDataResponseTaskFlyAway' - | 'CEventDataResponseTaskGrowlAndFlee' - | 'CEventDataResponseTaskGunAimedAt' - | 'CEventDataResponseTaskHandsUp' - | 'CEventDataResponseTaskHeadTrack' - | 'CEventDataResponseTaskLeaveCarAndFlee' - | 'CEventDataResponseTaskScenarioFlee' - | 'CEventDataResponseTaskSharkAttack' - | 'CEventDataResponseTaskShockingEventBackAway' - | 'CEventDataResponseTaskShockingEventGoto' - | 'CEventDataResponseTaskShockingEventHurryAway' - | 'CEventDataResponseTaskShockingEventReact' - | 'CEventDataResponseTaskShockingEventReactToAircraft' - | 'CEventDataResponseTaskShockingEventStopAndStare' - | 'CEventDataResponseTaskShockingEventThreatResponse' - | 'CEventDataResponseTaskShockingEventWatch' - | 'CEventDataResponseTaskShockingNiceCar' - | 'CEventDataResponseTaskShockingPoliceInvestigate' - | 'CEventDataResponseTaskThreat' - | 'CEventDataResponseTaskTurnToFace' - | 'CEventDataResponseTaskWalkAway' - | 'CEventDataResponseTaskWalkRoundEntity' - | 'CEventDataResponseTaskWalkRoundFire' - | 'CEventDeadPedFound' - | 'CEventDeath' - | 'CEventDecisionMakerResponse' - | 'CEventDisturbance' - | 'CEventDraggedOutCar' - | 'CEventEditableResponse' - | 'CEventEncroachingPed' - | 'CEventEntityDamaged' - | 'CEventEntityDestroyed' - | 'CEventExplosion' - | 'CEventExplosionHeard' - | 'CEventFireNearby' - | 'CEventFootStepHeard' - | 'CEventFriendlyAimedAt' - | 'CEventFriendlyFireNearMiss' - | 'CEventGetOutOfWater' - | 'CEventGivePedTask' - | 'CEventGroupScriptAI' - | 'CEventGroupScriptNetwork' - | 'CEventGunAimedAt' - | 'CEventGunShot' - | 'CEventGunShotBulletImpact' - | 'CEventGunShotWhizzedBy' - | 'CEventHelpAmbientFriend' - | 'CEventHurtTransition' - | 'CEventInAir' - | 'CEventInfo' - | 'CEventInfoBase' - | 'CEventInjuredCryForHelp' - | 'CEventLeaderEnteredCarAsDriver' - | 'CEventLeaderExitedCarAsDriver' - | 'CEventLeaderHolsteredWeapon' - | 'CEventLeaderLeftCover' - | 'CEventLeaderUnholsteredWeapon' - | 'CEventMeleeAction' - | 'CEventMustLeaveBoat' - | 'CEventNetworkAdminInvited' - | 'CEventNetworkAttemptHostMigration' - | 'CEventNetworkBail' - | 'CEventNetworkCashTransactionLog' - | 'CEventNetworkCheatTriggered' - | 'CEventNetworkClanInviteReceived' - | 'CEventNetworkClanJoined' - | 'CEventNetworkClanKicked' - | 'CEventNetworkClanLeft' - | 'CEventNetworkClanRankChanged' - | 'CEventNetworkCloudEvent' - | 'CEventNetworkCloudFileResponse' - | 'CEventNetworkEmailReceivedEvent' - | 'CEventNetworkEndMatch' - | 'CEventNetworkEndSession' - | 'CEventNetworkEntityDamage' - | 'CEventNetworkFindSession' - | 'CEventNetworkFollowInviteReceived' - | 'CEventNetworkHostMigration' - | 'CEventNetworkHostSession' - | 'CEventNetworkIncrementStat' - | 'CEventNetworkInviteAccepted' - | 'CEventNetworkInviteConfirmed' - | 'CEventNetworkInviteRejected' - | 'CEventNetworkJoinSession' - | 'CEventNetworkJoinSessionResponse' - | 'CEventNetworkOnlinePermissionsUpdated' - | 'CEventNetworkPedLeftBehind' - | 'CEventNetworkPickupRespawned' - | 'CEventNetworkPlayerArrest' - | 'CEventNetworkPlayerCollectedAmbientPickup' - | 'CEventNetworkPlayerCollectedPickup' - | 'CEventNetworkPlayerCollectedPortablePickup' - | 'CEventNetworkPlayerDroppedPortablePickup' - | 'CEventNetworkPlayerEnteredVehicle' - | 'CEventNetworkPlayerJoinScript' - | 'CEventNetworkPlayerLeftScript' - | 'CEventNetworkPlayerScript' - | 'CEventNetworkPlayerSession' - | 'CEventNetworkPlayerSpawn' - | 'CEventNetworkPresenceInvite' - | 'CEventNetworkPresenceInviteRemoved' - | 'CEventNetworkPresenceInviteReply' - | 'CEventNetworkPresenceTriggerEvent' - | 'CEventNetworkPresence_StatUpdate' - | 'CEventNetworkPrimaryClanChanged' - | 'CEventNetworkRequestDelay' - | 'CEventNetworkRosChanged' - | 'CEventNetworkScAdminPlayerUpdated' - | 'CEventNetworkScAdminReceivedCash' - | 'CEventNetworkScriptEvent' - | 'CEventNetworkSessionEvent' - | 'CEventNetworkShopTransaction' - | 'CEventNetworkSignInStateChanged' - | 'CEventNetworkSocialClubAccountLinked' - | 'CEventNetworkSpectateLocal' - | 'CEventNetworkStartMatch' - | 'CEventNetworkStartSession' - | 'CEventNetworkStorePlayerLeft' - | 'CEventNetworkSummon' - | 'CEventNetworkSystemServiceEvent' - | 'CEventNetworkTextMessageReceived' - | 'CEventNetworkTimedExplosion' - | 'CEventNetworkTransitionEvent' - | 'CEventNetworkTransitionGamerInstruction' - | 'CEventNetworkTransitionMemberJoined' - | 'CEventNetworkTransitionMemberLeft' - | 'CEventNetworkTransitionParameterChanged' - | 'CEventNetworkTransitionStarted' - | 'CEventNetworkTransitionStringChanged' - | 'CEventNetworkVehicleUndrivable' - | 'CEventNetworkVoiceConnectionRequested' - | 'CEventNetworkVoiceConnectionResponse' - | 'CEventNetworkVoiceConnectionTerminated' - | 'CEventNetworkVoiceSessionEnded' - | 'CEventNetworkVoiceSessionStarted' - | 'CEventNetworkWithData' - | 'CEventNetwork_InboxMsgReceived' - | 'CEventNewTask' - | 'CEventObjectCollision' - | 'CEventOnFire' - | 'CEventOpenDoor' - | 'CEventPedCollisionWithPed' - | 'CEventPedCollisionWithPlayer' - | 'CEventPedEnteredMyVehicle' - | 'CEventPedJackingMyVehicle' - | 'CEventPedOnCarRoof' - | 'CEventPedSeenDeadPed' - | 'CEventPlayerCollisionWithPed' - | 'CEventPlayerDeath' - | 'CEventPlayerUnableToEnterVehicle' - | 'CEventPotentialBeWalkedInto' - | 'CEventPotentialBlast' - | 'CEventPotentialGetRunOver' - | 'CEventPotentialWalkIntoVehicle' - | 'CEventProvidingCover' - | 'CEventRanOverPed' - | 'CEventReactionEnemyPed' - | 'CEventReactionInvestigateDeadPed' - | 'CEventReactionInvestigateThreat' - | 'CEventRequestHelp' - | 'CEventRequestHelpWithConfrontation' - | 'CEventRespondedToThreat' - | 'CEventScanner' - | 'CEventScenarioForceAction' - | 'CEventScriptCommand' - | 'CEventScriptWithData' - | 'CEventShocking' - | 'CEventShockingBicycleCrash' - | 'CEventShockingBicycleOnPavement' - | 'CEventShockingCarAlarm' - | 'CEventShockingCarChase' - | 'CEventShockingCarCrash' - | 'CEventShockingCarOnCar' - | 'CEventShockingCarPileUp' - | 'CEventShockingDangerousAnimal' - | 'CEventShockingDeadBody' - | 'CEventShockingDrivingOnPavement' - | 'CEventShockingEngineRevved' - | 'CEventShockingExplosion' - | 'CEventShockingFire' - | 'CEventShockingGunFight' - | 'CEventShockingGunshotFired' - | 'CEventShockingHelicopterOverhead' - | 'CEventShockingHornSounded' - | 'CEventShockingInDangerousVehicle' - | 'CEventShockingInjuredPed' - | 'CEventShockingMadDriver' - | 'CEventShockingMadDriverBicycle' - | 'CEventShockingMadDriverExtreme' - | 'CEventShockingMugging' - | 'CEventShockingNonViolentWeaponAimedAt' - | 'CEventShockingParachuterOverhead' - | 'CEventShockingPedKnockedIntoByPlayer' - | 'CEventShockingPedRunOver' - | 'CEventShockingPedShot' - | 'CEventShockingPlaneFlyby' - | 'CEventShockingPotentialBlast' - | 'CEventShockingPropertyDamage' - | 'CEventShockingRunningPed' - | 'CEventShockingRunningStampede' - | 'CEventShockingSeenCarStolen' - | 'CEventShockingSeenConfrontation' - | 'CEventShockingSeenGangFight' - | 'CEventShockingSeenInsult' - | 'CEventShockingSeenMeleeAction' - | 'CEventShockingSeenNiceCar' - | 'CEventShockingSeenPedKilled' - | 'CEventShockingSiren' - | 'CEventShockingStudioBomb' - | 'CEventShockingVehicleTowed' - | 'CEventShockingVisibleWeapon' - | 'CEventShockingWeaponThreat' - | 'CEventShockingWeirdPed' - | 'CEventShockingWeirdPedApproaching' - | 'CEventShoutBlockingLos' - | 'CEventShoutTargetPosition' - | 'CEventShovePed' - | 'CEventSoundBase' - | 'CEventStatChangedValue' - | 'CEventStaticCountReachedMax' - | 'CEventStuckInAir' - | 'CEventSuspiciousActivity' - | 'CEventSwitch2NM' - | 'CEventUnidentifiedPed' - | 'CEventVehicleCollision' - | 'CEventVehicleDamage' - | 'CEventVehicleDamageWeapon' - | 'CEventVehicleOnFire' - | 'CEventWrithe' + (typeof NATIVE_CLIENT_NETWORK_EVENTS)[number] diff --git a/rpc/src/utils/typing.ts b/rpc/src/utils/typing.ts new file mode 100644 index 0000000..1dc306e --- /dev/null +++ b/rpc/src/utils/typing.ts @@ -0,0 +1,47 @@ +/** + * **Internal** Typing helpers over the event maps of + * `@entityseven/fivem-rpc-shared-types`. A map with declarations is typed + * strictly; an empty map (nothing declared, or the package not installed) + * accepts any name, any arguments and any result. + */ + +// oxlint-disable-next-line typescript/no-explicit-any -- loose mode must accept listeners with any parameter types +type AnyEvent = (...args: any[]) => any + +/** `true` for `any`, which is what the maps become if the package is missing */ +type IsAny = 0 extends 1 & T ? true : false + +/** The declared map, or a loose one when nothing is declared */ +type EventMap = + IsAny extends true + ? Record + : [keyof T] extends [never] + ? Record + : T + +export type RPCEventName = keyof EventMap & string + +export type RPCEventArgs< + T, + K extends RPCEventName, +> = EventMap[K] extends (...args: infer A extends unknown[]) => unknown + ? A + : never + +export type RPCEventResult< + T, + K extends RPCEventName, +> = EventMap[K] extends (...args: never[]) => infer R ? Awaited : never + +/** Listener for event `K` of map `T`; `Prefix` goes before the event arguments */ +export type RPCListener< + T, + K extends RPCEventName, + Prefix extends unknown[] = [], +> = ( + ...args: [...Prefix, ...RPCEventArgs] +) => RPCEventResult | Promise> + +export type RPCCommandName = [keyof T] extends [never] + ? string + : keyof T & string diff --git a/rpc/tsconfig.json b/rpc/tsconfig.json index eb2d78a..348e028 100644 --- a/rpc/tsconfig.json +++ b/rpc/tsconfig.json @@ -1,19 +1,15 @@ { "compilerOptions": { - "target": "es6", - "module": "commonjs", - "moduleResolution": "node", - "lib": ["ES6", "dom"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, + "target": "es2022", + "lib": ["es2022", "dom"], + "module": "preserve", + "moduleResolution": "bundler", - "outDir": "dist", - "esModuleInterop": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noImplicitAny": true + "strict": true }, - "include": ["src/**/*"] + "include": ["src"] } diff --git a/rpc/tsdown.config.ts b/rpc/tsdown.config.ts new file mode 100644 index 0000000..470b01a --- /dev/null +++ b/rpc/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + platform: 'neutral', + target: 'es2022', + fixedExtension: true, + dts: true, + exports: true, +}) diff --git a/rpc/tsup.config.ts b/rpc/tsup.config.ts deleted file mode 100644 index c91be33..0000000 --- a/rpc/tsup.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - outDir: './dist', - target: 'node16', - platform: 'node', - format: ['cjs'], - splitting: false, - sourcemap: false, - clean: false, - experimentalDts: true, - noExternal: [/.*/], -}) diff --git a/shared-types/index.d.ts b/shared-types/index.d.ts new file mode 100644 index 0000000..549d315 --- /dev/null +++ b/shared-types/index.d.ts @@ -0,0 +1,58 @@ +/** + * Event and command declarations for `@entityseven/fivem-rpc`. + * + * Every interface starts empty: until you declare something in it, the matching + * rpc methods accept any name and any arguments. Declare your own by augmenting + * this module from any `.d.ts` (or `.ts`) file included in your project: + * + * @example + * // shared/rpc.d.ts + * import '@entityseven/fivem-rpc-shared-types' + * + * declare module '@entityseven/fivem-rpc-shared-types' { + * interface RPCEvents_ClientServer { + * // name(arguments): value returned by the listener + * buyItem(item: string, amount: number): boolean + * } + * interface RPCCommands_Server { + * ban: true + * } + * } + */ + +// ===== COMMANDS (names are the keys, values are not used) ===== + +/** Commands registered with `rpc.onCommand` on the client */ +export interface RPCCommands_Client {} + +/** Commands registered with `rpc.onCommand` on the server */ +export interface RPCCommands_Server {} + +// ===== EVENTS (caller -> receiver) ===== + +/** Client -> client: `emitSelf` / `onSelf` on the client */ +export interface RPCEvents_Client {} + +/** Client -> server: `emitServer` on the client, `onClient` on the server */ +export interface RPCEvents_ClientServer {} + +/** Client -> webview: `emitWebview` on the client, `onClient` on the webview */ +export interface RPCEvents_ClientWebview {} + +/** Server -> server: `emitSelf` / `onSelf` on the server */ +export interface RPCEvents_Server {} + +/** Server -> client: `emitClient` on the server, `onServer` on the client */ +export interface RPCEvents_ServerClient {} + +/** Server -> webview: `emitWebview` on the server, `onServer` on the webview */ +export interface RPCEvents_ServerWebview {} + +/** Webview -> webview: `emitSelf` / `onSelf` on the webview */ +export interface RPCEvents_Webview {} + +/** Webview -> client: `emitClient` on the webview, `onWebview` on the client */ +export interface RPCEvents_WebviewClient {} + +/** Webview -> server: `emitServer` on the webview, `onWebview` on the server */ +export interface RPCEvents_WebviewServer {} diff --git a/shared-types/package.json b/shared-types/package.json index 344657c..7ad6c3a 100644 --- a/shared-types/package.json +++ b/shared-types/package.json @@ -1,34 +1,51 @@ { "name": "@entityseven/fivem-rpc-shared-types", - "description": "Shared (enhanced) types for @entityseven/fivem-rpc. Highly recommended to install together", - "version": "0.1.0", - "types": "types/types/index.d.ts", - "files": [ - "types/**/*", - "readme.md", - "license.md" - ], + "version": "1.0.0", + "description": "Event and command declarations for @entityseven/fivem-rpc, for typed events", "keywords": [ - "fivem-rpc-shared-types", - "fivem-rpc", + "cfx", "fivem", - "gta" + "fivem-rpc", + "types", + "typescript" ], - "type": "module", + "license": "SEE LICENSE IN license.md", "author": "Entity Seven Group", "contributors": [ { "name": "Danya H", "email": "dev.rilaxik@gmail.com", "url": "https://github.com/rilaxik/" + }, + { + "name": "Oleksandr Honcharov", + "email": "0976053529@ukr.net", + "url": "https://github.com/SashaGoncharov19/" } ], - "license": "Custom-Attribution-NoDerivs", "repository": { "type": "git", - "url": "https://github.com/rilaxik/fivem-rpc.git" + "url": "https://github.com/rilaxik/fivem-rpc.git", + "directory": "shared-types" + }, + "files": [ + "index.d.ts", + "readme.md", + "license.md" + ], + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts" + }, + "./package.json": "./package.json" }, "peerDependencies": { - "typescript": "^5" + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } } diff --git a/shared-types/readme.md b/shared-types/readme.md index 2a23bfd..c0abcd9 100644 --- a/shared-types/readme.md +++ b/shared-types/readme.md @@ -1,168 +1,61 @@ # FiveM RPC Shared Types -### [Docs & Info](https://github.com/rilaxik/fivem-rpc/blob/master/readme.md) + +Event and command declarations for [`@entityseven/fivem-rpc`](../rpc/readme.md). With nothing declared, every rpc method accepts any event name, any arguments and any result. Declare your events once and every `on*` and `emit*` call gets checked names, arguments and results ## Installation -```bash - pnpm i @entityseven/fivem-rpc-shared-types -D -``` -```bash - yarn add @entityseven/fivem-rpc-shared-types --dev -``` -```bash - bun add @entityseven/fivem-rpc-shared-types -d -``` + +See the [main readme](../readme.md#installation). The declaration file below imports this package, so it must resolve from that file (in a workspace: install it in the root) ## Usage -This package is an enhanced type support for `@entityseven/fivem-rpc`. It provides ability to strictly type your events for better dx. -## Example -This example is neat way to follow up but you can change it as you wish. It will use bun workspaces, pnpm workspaces work in a similar manner. Referring the following folder structure: -```markdown -apps/ - - server/ - - package.json - - tsconfig.json - - client/ - - package.json - - tsconfig.json - - webview/ - - package.json - - tsconfig.json - - shared/ (this must be available in server, client and webview) - - package.json +1. Create one declaration file shared by server, client and webview code, e.g. `shared/rpc.d.ts`: - - package.json (root package) - - pnpm-workspace.yaml (only if using pnpm) -``` + ```ts + import '@entityseven/fivem-rpc-shared-types' -- Environment folder: folder with server, client or webview code in it + declare module '@entityseven/fivem-rpc-shared-types' { + interface RPCEvents_ClientServer { + // event name(arguments): value returned by the listener + buyItem(item: string, amount: number): boolean + } + interface RPCCommands_Server { + ban: true + } + } + ``` -1. Install this package as dev dependency in your root package or in each environment folder separately - ```markdown - apps/ - - server/ <- (if not installed root) - - client/ <- (if not installed root) - - webview/ <- (if not installed root) - - shared/ - - - package.json <- here - ``` + The `import` line makes the file a module, so `declare module` adds to the package's interfaces instead of replacing them -2. In `shared/` create folder `fivem-rpc` (or similar), inside it create `index.d.ts` - ```markdown - apps/ - - server/ - - client/ - - webview/ - - shared/ - - fivem-rpc/ - - index.d.ts <- here - - - package.json - ``` +2. Add the file to `include` in the `tsconfig.json` of every environment: -3. In `index.d.ts` add following: - ```ts - declare module '@entityseven/fivem-rpc-shared-types' { - // Client commands names - export type RPCCommands_Client = '' - - // Server commands names - export type RPCCommands_Server = '' - - // Client -> Client events - export interface RPCEvents_Client {} - - // Client -> Server events - export interface RPCEvents_ClientServer {} - - // Client -> Webview events - export interface RPCEvents_ClientWebview {} - - // Server -> Server events - export interface RPCEvents_Server {} - - // Server -> Client events - export interface RPCEvents_ServerClient {} - - // Server -> Server events - export interface RPCEvents_ServerWebview {} - - // Webview -> Webview events - export interface RPCEvents_Webview {} - - // Webview -> Client events - export interface RPCEvents_WebviewClient {} - - // Webview -> Server events - export interface RPCEvents_WebviewServer {} - } - ``` + ```json + { + "include": ["src", "../shared/rpc.d.ts"] + } + ``` -4. We just created a declaration which will overwrite types from the package. Now we need our packages to refer to these types when linting `rpc` functions. To do this in each environment folder of your project in `tsconfig.json` do these: - ```json5 - { - "compilerOptions": { - "types": [ - "../shared/fivem-rpc/" // or your specific folder - ] - } - } - ``` -5. We also need to populate interfaces we created in step 3. -- `RPCCommands_Client` and `RPCCommands_Server` will include your commands names an union strings: - ```ts - export type RPCCommands_Client = 'afk' | 'vanish' | '...' // example names - export type RPCCommands_Server = 'report' | 'ban' | '...' // example names - ``` -- Other interfaces will include your events types. The example will show one but all of the work same way - ```ts - export interface RPCEvents_ClientServer { - clientToServerEventName(data: string, moreData: boolean): number - "client-to-server-event-name"(data: string, moreData: boolean): number // can also include characters you cannot use as variable or function names - } - ``` - - `clientToServerEventName` or `client-to-server-event-name` is an event name - - `data` and `moreData` are the arguments you need to pass when calling an event and argument you will receive when listening (may also include extra, as player, check type hints) - - `number` is a return type that will be forwarded back to caller - - Doing this will create type hints for you: - ```ts - // assuming this is in client - const response /* number */ = await rpc.emitServer( - 'clientToServerEventName', /* suggested name */ - 'data', /* will pass typecheck */ - 'moreData', /* will NOT pass typecheck, since required type is `boolean` */ - ) - ``` +3. Calls are checked from now on: -## Example (alternative) -If previous example does not work or you do not like you can also try it this way. Steps that are not mentioned are the same as previous + ```ts + // server + rpc.onClient('buyItem', (player, item, amount) => { + // item: string, amount: number, must return boolean + return true + }) -2. In `shared/` create folders `declarations/fivem-rpc`, inside it create `index.d.ts` - ```markdown - apps/ - - server/ - - client/ - - webview/ - - shared/ - - declarations/ - - fivem-rpc/ - - index.d.ts <- here - - - package.json - ``` + // client + const bought = await rpc.emitServer('buyItem', 'water', 2) // boolean + await rpc.emitServer('buyItem', 'water') // error: missing `amount` + await rpc.emitServer('buyItme', 'water', 2) // error: unknown event + ``` -4. We just created a declaration which will overwrite types from the package. Now we need our packages to refer to these types when linting `rpc` functions. To do this in each environment folder of your project in `tsconfig.json` do these: - ```json5 - { - "compilerOptions": { - "typeRoots": [ - "../shared/declarations/", // or your specific folder - "../../node_modules/@types", // you may also want to add this if some of your other libraries are not showing types now - ] - } - } - ``` +## Interfaces -If a any point this stops working for you, do your research on how to redeclare library types and refer to it \ No newline at end of file +Each interface types one direction, see the [direction table](../rpc/readme.md#directions) for which `emit*` and `on*` methods use it. `RPCCommands_Server` and `RPCCommands_Client` type `onCommand`. An interface you leave empty stays loose (any name, arguments and result), so you can declare them one at a time + +- events: the member name is the event name, its parameters are the arguments, its return type is what `emit*` resolves with. Server `onClient` and `onWebview` listeners get the player id before the declared arguments. Names that are not identifiers work too: `'buy-item'(item: string): boolean` +- commands: the key is the command name, the value is not used (`true`) + +## License + +Licensed under the [Custom Attribution-NoDerivs Software License](license.md) diff --git a/shared-types/types/types/index.d.ts b/shared-types/types/types/index.d.ts deleted file mode 100644 index 4c2634d..0000000 --- a/shared-types/types/types/index.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -declare module '@entityseven/fivem-rpc-shared-types' { - // Client commands names - export type RPCCommands_Client = '' - - // Server commands names - export type RPCCommands_Server = '' - - // Client -> Client events - export interface RPCEvents_Client { - _(): void - } - - // Client -> Server events - export interface RPCEvents_ClientServer { - _(): void - } - - // Client -> Webview events - export interface RPCEvents_ClientWebview { - _(): void - } - - // Server -> Server events - export interface RPCEvents_Server { - _(): void - } - - // Server -> Client events - export interface RPCEvents_ServerClient { - _(): void - } - - // Server -> Server events - export interface RPCEvents_ServerWebview { - _(): void - } - - // Webview -> Webview events - export interface RPCEvents_Webview { - _(): void - } - - // Webview -> Client events - export interface RPCEvents_WebviewClient { - _(): void - } - - // Webview -> Server events - export interface RPCEvents_WebviewServer { - _(): void - } -}