├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── eslintrc.json ├── index.ts ├── package-lock.json ├── package.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": ["prettier"], 4 | "extends": ["airbnb-base", "prettier", "plugin:prettier/recommended"] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quotePreps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "endOfLine": "lf" 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2022 Daniel Syomichev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source Server Query 2 | 3 | A library for querying Source servers using the [Source Query Protocol](https://developer.valvesoftware.com/wiki/Server_queries). Execute A2S_INFO, A2S_PLAYER, and A2S_RULES server queries. Responses will be returned in an array or object depending on the request. All methods are asynchronous resulting in a clean and easy way to query many servers one by one should it be necessary. 4 | 5 | ## Installing 6 | 7 | You can add this package to your own project using npm. 8 | 9 | ``` 10 | $ npm install source-server-query 11 | ``` 12 | 13 | Then load it into your own project. This project includes type declarations for Typescript. 14 | 15 | ```typescript 16 | import query from 'source-server-query'; 17 | 18 | /* OR */ 19 | 20 | const query = require('source-server-query'); 21 | ``` 22 | 23 | Or create the object using a constructor. 24 | 25 | ```typescript 26 | import { SourceQuerySocket } from 'source-server-query'; 27 | 28 | const query: SourceQuerySocket = new SourceQuerySocket(); 29 | 30 | /* OR */ 31 | 32 | const { SourceQuerySocket } = require('source-server-query'); 33 | 34 | const query = new SourceQuerySocket(); 35 | ``` 36 | 37 | ## Usage 38 | 39 | Each method, `info`, `players`, `rules`, uses the same arguments in the form of an address and port. The port is the UDP query port, not the game port. An optional timeout can be provided as well. 40 | 41 | ```javascript 42 | query.info('127.0.0.1', 27015, 1000).then(console.log); // A2S_INFO 43 | 44 | query.players('127.0.0.1', 27015, 1000).then(console.log); // A2S_PLAYER 45 | 46 | query.rules('127.0.0.1', 27015, 1000).then(console.log); // A2S_RULES 47 | ``` 48 | 49 | The socket binding options can be configured through properties on the object, which include `port`, `address`, `exclusive`, and `fd`. 50 | 51 | ```javascript 52 | socket.port = 8080; 53 | socket.address = '127.0.0.1'; 54 | ``` 55 | 56 | The socket opens and closes automatically, and whenever the socket is opened, the configuration is pushed to the socket. To force an update, close the current socket with the `close()` method. It will reopen with the updated values when the next request is sent. 57 | 58 | ## License 59 | 60 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 61 | -------------------------------------------------------------------------------- /eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": ["@typescript-eslint"], 8 | "extends": ["airbnb-typescript/base", "prettier", "plugin:prettier/recommended"] 9 | } 10 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import dgram, { BindOptions, Socket } from 'dgram'; 2 | import { AddressInfo } from 'net'; 3 | 4 | class SourceQuerySocket { 5 | public port?: number; 6 | 7 | public address?: string; 8 | 9 | public exclusive?: boolean; 10 | 11 | public fd?: number; 12 | 13 | private socket?: Socket; 14 | 15 | public constructor(options: BindOptions = {}) { 16 | if (options.port !== undefined) this.port = options.port; 17 | if (options.address !== undefined) this.address = options.address; 18 | if (options.exclusive !== undefined) this.exclusive = options.exclusive; 19 | if (options.fd !== undefined) this.fd = options.fd; 20 | } 21 | 22 | private bind(): Promise { 23 | return new Promise((resolve, reject): void => { 24 | const error = (err: unknown): void => { 25 | this.socket?.close(); 26 | return reject(err); 27 | }; 28 | 29 | const listening = (): void => { 30 | this.socket?.removeListener('error', error); 31 | return resolve(); 32 | }; 33 | 34 | this.socket = dgram.createSocket('udp4'); 35 | 36 | this.socket.once('error', error); 37 | this.socket.once('listening', listening); 38 | 39 | this.socket.bind({ port: this.port, address: this.address, exclusive: this.exclusive, fd: this.fd }); 40 | }); 41 | } 42 | 43 | private assert(): Promise { 44 | return new Promise(async (resolve, reject): Promise => { 45 | if (this.socket === undefined) { 46 | try { 47 | await this.bind(); 48 | } catch (err) { 49 | return reject(err); 50 | } 51 | 52 | return resolve(); 53 | } 54 | 55 | try { 56 | this.socket?.address(); 57 | } catch (err: unknown) { 58 | return this.socket?.once('listening', () => resolve()) as unknown as void; 59 | } 60 | 61 | return resolve(); 62 | }); 63 | } 64 | 65 | private close = (): void => { 66 | this.socket?.close(); 67 | this.socket = undefined; 68 | }; 69 | 70 | private validate(einfo: AddressInfo, rinfo: AddressInfo, request: Buffer, response: Buffer): boolean { 71 | if (rinfo.port !== einfo.port) return false; 72 | if (rinfo.address !== einfo.address) return false; 73 | 74 | return true; 75 | } 76 | 77 | private send(einfo: AddressInfo, request: Buffer, duration: number): Promise { 78 | return new Promise(async (resolve, reject): Promise => { 79 | try { 80 | await this.assert(); 81 | } catch (err) { 82 | return reject(err); 83 | } 84 | 85 | const timeout: NodeJS.Timeout = setTimeout((): void => { 86 | this.socket?.removeListener('message', message); 87 | return reject(new Error(`Request timed out. [${duration}ms]`)); 88 | }, duration); 89 | 90 | const message = (response: Buffer, rinfo: AddressInfo): void => { 91 | if (this.validate(einfo, rinfo, request, response) === false) return; 92 | 93 | clearTimeout(timeout); 94 | this.socket?.removeListener('message', message); 95 | if (this.socket?.listenerCount('message') === 0) this.close(); 96 | 97 | return resolve(response); 98 | }; 99 | 100 | this.socket?.on('message', message); 101 | this.socket?.send(request, einfo.port, einfo.address, (err): void => { 102 | if (err !== undefined && err !== null) return reject(err); 103 | }); 104 | }); 105 | } 106 | 107 | private pack(header: string, payload?: string, challenge?: number): Buffer { 108 | const preamble: Buffer = Buffer.alloc(4); 109 | preamble.writeInt32LE(-1, 0); 110 | 111 | const request: Buffer = Buffer.from(header); 112 | 113 | const data: Buffer = payload ? Buffer.concat([Buffer.from(payload), Buffer.alloc(1)]) : Buffer.alloc(0); 114 | 115 | let prologue: Buffer = Buffer.alloc(0); 116 | if (challenge !== undefined) { 117 | prologue = Buffer.alloc(4); 118 | prologue.writeInt32LE(challenge); 119 | } 120 | 121 | return Buffer.concat([preamble, request, data, prologue, preamble]); 122 | } 123 | 124 | private async solicit( 125 | einfo: AddressInfo, 126 | header: string, 127 | payload: string | undefined, 128 | duration: number 129 | ): Promise { 130 | const request: Buffer = this.pack(header, payload); 131 | const challenge: Buffer = await this.send(einfo, request, duration); 132 | const type: string = challenge.slice(4, 5).toString(); 133 | 134 | if (type === 'A') { 135 | const challenger: number = challenge.readInt32LE(5); 136 | const result: Buffer = this.pack(header, payload, challenger); 137 | 138 | return await this.send(einfo, result, duration); 139 | } 140 | 141 | return challenge; 142 | } 143 | 144 | public info = async (address: string, port: number | string, timeout: number = 1000) => { 145 | const query: Buffer = await this.solicit( 146 | { address, port: parseInt(port as string, 10), family: '' }, 147 | 'T', 148 | 'Source Engine Query', 149 | timeout 150 | ); 151 | 152 | const result: Record = {}; 153 | let offset = 4; 154 | 155 | result.header = query.slice(offset, offset + 1); 156 | offset += 1; 157 | result.header = result.header.toString(); 158 | 159 | result.protocol = query.readInt8(offset); 160 | offset += 1; 161 | 162 | result.name = query.slice(offset, query.indexOf(0, offset)); 163 | offset += result.name.length + 1; 164 | result.name = result.name.toString(); 165 | 166 | result.map = query.slice(offset, query.indexOf(0, offset)); 167 | offset += result.map.length + 1; 168 | result.map = result.map.toString(); 169 | 170 | result.folder = query.slice(offset, query.indexOf(0, offset)); 171 | offset += result.folder.length + 1; 172 | result.folder = result.folder.toString(); 173 | 174 | result.game = query.slice(offset, query.indexOf(0, offset)); 175 | offset += result.game.length + 1; 176 | result.game = result.game.toString(); 177 | 178 | result.id = query.readInt16LE(offset); 179 | offset += 2; 180 | 181 | result.players = query.readInt8(offset); 182 | offset += 1; 183 | 184 | result.max_players = query.readInt8(offset); 185 | offset += 1; 186 | 187 | result.bots = query.readInt8(offset); 188 | offset += 1; 189 | 190 | result.server_type = query.slice(offset, offset + 1).toString(); 191 | offset += 1; 192 | 193 | result.environment = query.slice(offset, offset + 1).toString(); 194 | offset += 1; 195 | 196 | result.visibility = query.readInt8(offset); 197 | offset += 1; 198 | 199 | result.vac = query.readInt8(offset); 200 | offset += 1; 201 | 202 | result.version = query.slice(offset, query.indexOf(0, offset)); 203 | offset += result.version.length + 1; 204 | result.version = result.version.toString(); 205 | 206 | const extra: Buffer = query.slice(offset); 207 | 208 | offset = 0; 209 | if (extra.length < 1) return result; 210 | 211 | const edf: number = extra.readInt8(offset); 212 | offset += 1; 213 | 214 | if (edf & 0x80) { 215 | result.port = extra.readInt16LE(offset); 216 | offset += 2; 217 | } 218 | 219 | if (edf & 0x10) { 220 | result.steamid = extra.readBigUInt64LE(offset); 221 | offset += 8; 222 | } 223 | 224 | if (edf & 0x40) { 225 | result.tvport = extra.readInt16LE(offset); 226 | offset += 2; 227 | 228 | result.tvname = extra.slice(offset, extra.indexOf(0, offset)); 229 | offset += result.tvname.length + 1; 230 | result.tvname = result.tvname.toString(); 231 | } 232 | 233 | if (edf & 0x20) { 234 | const keywords: Buffer = extra.slice(offset, extra.indexOf(0, offset)); 235 | offset += keywords.length + 1; 236 | 237 | result.keywords = keywords.toString(); 238 | } 239 | 240 | if (edf & 0x01) { 241 | result.gameid = extra.readBigUInt64LE(offset); 242 | offset += 8; 243 | } 244 | 245 | return result; 246 | }; 247 | 248 | public players = async (address: string, port: number | string, timeout: number = 1000) => { 249 | const query: Buffer = await this.solicit( 250 | { address, port: parseInt(port as string, 10), family: '' }, 251 | 'U', 252 | undefined, 253 | timeout 254 | ); 255 | 256 | let offset = 5; 257 | const count: number = query.readInt8(offset); 258 | offset += 1; 259 | 260 | const result: Record[] = []; 261 | for (let i = 0; i < count; i += 1) { 262 | const player: Record = {}; 263 | 264 | player.index = query.readInt8(offset); 265 | offset += 1; 266 | 267 | player.name = query.slice(offset, query.indexOf(0, offset)); 268 | offset += player.name.length + 1; 269 | player.name = player.name.toString(); 270 | 271 | player.score = query.readInt32LE(offset); 272 | offset += 4; 273 | 274 | player.duration = query.readFloatLE(offset); 275 | offset += 4; 276 | 277 | result.push(player); 278 | } 279 | 280 | return result; 281 | }; 282 | 283 | public rules = async (address: string, port: string | number, timeout = 1000) => { 284 | const query: Buffer = await this.solicit( 285 | { address, port: parseInt(port as string, 10), family: '' }, 286 | 'V', 287 | undefined, 288 | timeout 289 | ); 290 | 291 | let offset = 0; 292 | const header: number = query.readInt32LE(offset); 293 | if (header === -2) throw new Error('Unsupported response received.'); 294 | offset += 4; 295 | 296 | offset += 1; 297 | 298 | const count: number = query.readInt16LE(offset); 299 | offset += 2; 300 | 301 | const result: Record[] = []; 302 | for (let i = 0; i < count; i += 1) { 303 | const rule: Record = {}; 304 | 305 | rule.name = query.slice(offset, query.indexOf(0, offset)); 306 | offset += rule.name.length + 1; 307 | rule.name = rule.name.toString(); 308 | 309 | rule.value = query.slice(offset, query.indexOf(0, offset)); 310 | offset += rule.value.length + 1; 311 | rule.value = rule.value.toString(); 312 | 313 | result.push(rule); 314 | } 315 | 316 | return result; 317 | }; 318 | } 319 | 320 | module.exports = new SourceQuerySocket(); 321 | module.exports.SourceQuerySocket = SourceQuerySocket; 322 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "source-server-query", 3 | "version": "3.0.3", 4 | "description": "Query Source game servers using the Source Query Protocol.", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "prepare": "npx tsc" 10 | }, 11 | "homepage": "https://github.com/dsyomichev/source-server-query", 12 | "bugs": "https://github.com/dsyomichev/source-server-query/issuess", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/dsyomichev/source-server-query.git" 16 | }, 17 | "author": { 18 | "name": "Daniel Syomichev", 19 | "email": "dsyomichev@gmail.com", 20 | "url": "https://github.com/dsyomichev" 21 | }, 22 | "files": [ 23 | "dist/index.js", 24 | "dist/index.d.ts" 25 | ], 26 | "devDependencies": { 27 | "@tsconfig/recommended": "^1.0.1", 28 | "@types/node": "^15.0.3", 29 | "@typescript-eslint/eslint-plugin": "^4.23.0", 30 | "@typescript-eslint/parser": "^4.23.0", 31 | "eslint": "^7.26.0", 32 | "eslint-config-airbnb-typescript": "^12.3.1", 33 | "eslint-config-prettier": "^8.3.0", 34 | "eslint-plugin-import": "^2.22.1", 35 | "eslint-plugin-prettier": "^3.4.0", 36 | "prettier": "^2.3.0", 37 | "ts-node": "^9.1.1", 38 | "typescript": "^4.4.3" 39 | }, 40 | "keywords": [ 41 | "steam", 42 | "source", 43 | "query" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "lib": ["es2020"], 6 | "declaration": true 7 | }, 8 | "include": ["index.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | --------------------------------------------------------------------------------