diff --git a/README.md b/README.md index 154acc3..88039da 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,43 @@ A communication layer between node.js and BYOND game servers. ### Example ```javascript -const http2byond = require("./index.js"); -let connection = new http2byond({ - timeout: 2000 +const {createTopicConnection} = require("./index.js"); +let connection = createTopicConnection({ + host: "localhost", + port: 6666 +}) + +connection.send("status").then((body) => { + console.log(body); +}, (err) => { + console.error("ERR", err); }); -var form = { - ip: "localhost", - port: "6666", - topic: "?status" -}; +async function anAsyncFunction() { + const result = connection.send("status"); +} +``` + +OR -connection.run(form).then((body) => { - console.log(body); +```javascript +const {sendTopic} = require("./index.js"); + +sendTopic({ + host: "localhost", + port: 6666, + topic: "status" +}).then((body) => { + console.log(body); }, (err) => { - console.error("ERR", err); + console.error("ERR", err); }); + +async function anAsyncFunction() { + const result = await sendTopic({ + host: "localhost", + port: 6666, + topic: "status" + }); +} ``` \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 7a1e361..0000000 --- a/index.js +++ /dev/null @@ -1,166 +0,0 @@ -// Dependencies. -const net = require('net'); -const jspack = require('jspack'); - -/** Class that handles byond connections - * @param {Object} config - Configuration object - * @param {Number} config.timeout - The timeout value to use for the socket. This is the minimum time that a request will take. - */ -class http2byond { - constructor (config) { - this.timeout = (config && config.timeout) || 2000; - } - - - /** - * Helper function, converts a string into a list representing each of it's characters by charCode - * @param {string} str - The string to convert.\ - * @returns {Array} Array full of charcodes. - */ - string_to_charcodes (str) { - var retArray = [] - for (var i = 0; i < str.length; i++) { - retArray.push(str.charCodeAt(i)); - } - return retArray; - } - - /** - * Async communication with BYOND gameservers. - * @param {Object} form - Settings object. - * @param {string} form.ip - IP Address to communicate with. - * @param {string} form.port - Port to use with the IP Address. Must match the port the game server is running on. - * @param {string} form.topic - URL parameters to send to the gameserver. Must start with `?`. - * @returns {Promise} Promise object represents the data or error received in return. - */ - run(form) { - var self = this; - return new Promise((resolve, reject) => { - var parameters = form.topic; - if (parameters.charAt(0) !== "?") - parameters = "?" + parameters; - - // Custom packet creation- BYOND expects special packets, this is based off /tg/'s PHP scripts containing a reverse engineered packet format. - var query = [0x00, 0x83]; - - // Use an unsigned short for the "expected data length" portion of the packet. - var pack = jspack.jspack.Pack("H", [parameters.length + 6]); - query = query.concat(pack); - - // Padding between header and actual data. - query = query.concat([0x00, 0x00, 0x00, 0x00, 0x00]); - // Convert data into charcodes and add it to the array - query = query.concat(self.string_to_charcodes(parameters)); - query.push(0x00); - - // Convert our new hex string into an actual buffer. - var querybuff = Buffer.from(query); - - /* Networking section */ - /* Now that we have our data in a binary buffer, start sending and receiving data. */ - - // Uses a normal net.Socket to send the custom packets. - var socket = new net.Socket({ - readable: true, - writable: true - }); - - // Timeout handler. Removed upon successful connection. - var tHandler = function () { - reject(new Error("Connection failed.")); - socket.destroy(); - }; - - // Timeout after self.timeout (default 2) seconds of inactivity, the game server is either extremely laggy or isn't up. - socket.setTimeout(self.timeout); - // Add the event handler. - socket.on("timeout", tHandler); - - // Error handler. If an error happens in the socket API, it'll be given back to us here. - var eHandler = function (err) { - reject(err); - socket.destroy(); - }; - - // Add the error handler. - socket.on("error", eHandler); - - // Establish the connection to the server. - socket.connect({ - port: form.port, - host: form.ip, - family: 4 // Use IPv4. - }); - - socket.on("connect", function () { // Socket successfully opened to the server. Ready to send and receive data. - // The timeout handler will interfere later, as the game server never sends an END packet. - // So, we just wait for it to time out to ensure we have all the data. - socket.removeListener("timeout", tHandler); - - // Send the custom buffer data over the socket. - socket.write(querybuff); - - // Function decodes the returned data once it's fully assembled. - function decode_buffer(dbuff) { - if (!dbuff) { - reject(new Error("No data received.")); - return null; - } - // Confirm the return packet is in the BYOND format. - if (dbuff[0] == 0x00 && dbuff[1] == 0x83) { - // Start parsing the output. - var sizearray = [dbuff[2], dbuff[3]]; // Array size of the type identifier and content. - var sizebytes = jspack.jspack.Unpack("H", sizearray); // It's packed in an unsigned short format, so unpack it as an unsigned short. - var size = sizebytes[0] - 1; // Byte size of the string/floating-point (minus the identifier byte). - - if (dbuff[4] == 0x2a) { // 4-byte little-endian floating point data. - var unpackarray = [dbuff[5], dbuff[6], dbuff[7], dbuff[8]]; - var unpackint = jspack.jspack.Unpack(" 0) { - size--; - unpackString += String.fromCharCode(dbuff[index]); - index++; - } - - return unpackString; - } - - // Something went wrong, the packet contains no apparent data. Error as "no data returned". - reject(new Error("No data returned.")); - return null; - } - } - - // Receive data in the form of a buffer. - var assembledBuffer; - socket.on("data", function (rbuff) { - if (assembledBuffer) { - assembledBuffer = Buffer.concat([assembledBuffer, rbuff]); - } else { - assembledBuffer = rbuff; - } - }); - - // Since BYOND doesn't send END packets, wait for timeout before trying to parse the returned data. - socket.on("timeout", function () { - // Decode the assembled data. - var received_data = decode_buffer(assembledBuffer); - // The catch will deal with any errors from decode_buffer, but it could fail without erroring, so, make sure there's any data first. - if (received_data) { - resolve(received_data); - } - - // Assume the socket is done sending data, and close the connection. - socket.end(); - }); - }); - }); - } -}; - -module.exports = http2byond; diff --git a/package-lock.json b/package-lock.json index 5bd5888..df7dfaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "http2byond", - "version": "3.0.0", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "http2byond", - "version": "3.0.0", + "version": "2.1.0", "license": "MIT", "devDependencies": { "@types/node": "^17.0.22", diff --git a/package.json b/package.json index 6e4a1a9..9f89031 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "http2byond", "description": "Communication layer between node.js and BYOND game servers.", - "version": "3.0.0", + "version": "2.1.0", "scripts": { "prepare": "npx tsc" }, diff --git a/src/http2byond.ts b/src/http2byond.ts new file mode 100644 index 0000000..922d369 --- /dev/null +++ b/src/http2byond.ts @@ -0,0 +1,186 @@ +import { createConnection, Socket } from 'net' +import { SingleRunConfiguration, SocketConfig, TopicConnection, TopicReturnType } from './types' + +/** + * @internal + */ +export function _sendTopic(socket: Socket, _topic: string): Promise { + const topic = _topic[0] !== "?" ? + "?" + _topic : + _topic + + return new Promise((resolve, reject) => { + function errorHandler(reason: string) { + return function(originalError?: Error) { + originalError ? + reject(new Error(reason + "\n Caused by: " + originalError)) : + reject(new Error(reason)) + socket.destroy() + } + } + + socket.once("error", errorHandler("Socket errored")) + socket.once("timeout", errorHandler("Connection timeout")) + + let byte = 0 + //type(2) + length(2) + const headerBuffer = Buffer.alloc(4) + let bodyBuffer: Buffer; + + function processResponse() { + //Get rid of all listeners to prepare the socket to be reused + socket.removeAllListeners() + const type = bodyBuffer[0]; + switch (type) { + case 0x00: { + return resolve(null) + } + case 0x2a: { + return resolve(bodyBuffer.readFloatLE(1)) + } + case 0x06: { + return resolve(bodyBuffer.subarray(0, -1).toString("utf-8")) + } + } + } + + + socket.once("data", data => { + //Still waiting on a complete header + if(byte < 4) { + const copiedBytes = data.copy(headerBuffer) + data = data.subarray(copiedBytes) + byte += copiedBytes + + //Got the full header! + if(byte >= 4) { + bodyBuffer = Buffer.alloc(headerBuffer.readUint16BE(2)) + //Sucks to be you, maybe you'll get a full header later? + } else { + return + } + } + + //We either just finished reading the header and got more to read, or we just got another body packet + if (data.length) { + const copiedBytes = data.copy(bodyBuffer) + byte += copiedBytes + } + + //expected buffer length + the header length + const fullLength = 4 + bodyBuffer.length + + //We got too many bytes! + if(byte > fullLength) { + errorHandler("Data is larger than expected") + return + } + + //No more data to read + if(byte === fullLength) { + processResponse() + } + }) + + function sendRequest() { + const topicBuffer = Buffer.from(topic) + //type(2) + length(2) + padding(5) + msg(n) + terminator(1) + const dataBuffer = Buffer.alloc(2 + 2 + 5 + topicBuffer.length + 1) + let ptr; + + //Packet type 0x0083 + dataBuffer[0] = 0x00 + dataBuffer[1] = 0x83 + + //Write length of buffer + //padding(5) + msg(n) + terminator(1) + dataBuffer.writeUInt16BE(5 + topicBuffer.length + 1, 2) + + //Write padding + dataBuffer[4] = 0x00 + dataBuffer[5] = 0x00 + dataBuffer[6] = 0x00 + dataBuffer[7] = 0x00 + dataBuffer[8] = 0x00 + + //We're done with the header, we need to write the null terminated string to the 8th byte. + ptr = 9; + topicBuffer.copy(dataBuffer, ptr) + ptr += topicBuffer.length + dataBuffer[ptr] = 0x00 + + console.log(dataBuffer) + socket.write(dataBuffer) + } + + //@ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/59397 lib definitions are missing a property + //Send the request either as soon as the socket is ready or immediatly if its already ready + if(socket.readyState === "open") { + sendRequest() + } else { + socket.once("ready", sendRequest) + } + }) +} + +export function sendTopic(config: SingleRunConfiguration): Promise { + const socket = createConnection({ + family: 4, + host: config.host, + port: config.port, + timeout: config.timeout + }) + return _sendTopic(socket, config.topic).then(val => { + socket.end() + return val + }) +} + +export function createTopicConnection(config: SocketConfig): TopicConnection { + const socket = createConnection({ + family: 4, + host: config.host, + port: config.port + }) + const queue: Array<[query: string, resolve: (value: TopicReturnType) => void, reject: (reason?: any) => void]> = [] + let busy = false + + function runNextTopic(value: T) { + const next = queue.shift() + if(!next) { + busy = false + return value + } + const [topic, resolve, reject] = next; + _sendTopic(socket, topic).then(runNextTopic).then(resolve).catch(reject) + + return value + } + + return { + send: topic => { + if (socket.destroyed) { + return Promise.reject(new Error("Socket is destroyed")) + } + //Immediate + if(!busy) { + busy = true + return _sendTopic(socket, topic).then(runNextTopic) + } + + return new Promise((resolve, reject) => { + queue.push([topic, resolve, reject]) + }) + }, + destroy: () => { + socket.destroy() + }, + get queueLength() { + //Busy means we are processing a query right now + return queue.length + Number(busy) + }, + get destroyed() { + return socket.destroyed + } + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3d720f5..ab52e09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,163 +1,4 @@ -import { Socket, createConnection } from 'net' +import Shim from "./shim" -export interface SocketConfig { - //Domain name or IP to connect to - host: string; - //Port to connect to - port: number; -} -export interface SingleRunConfiguration extends SocketConfig { - //URL params to send to BYOND - topic: string; - //Time to wait before aborting connection - timeout?: number; -} -export type TopicReturnType = string | number | null - -export interface TopicConnection { - send: (topic: string) => Promise - destroy: () => void -} - -function _sendTopic(socket: Socket, _topic: string): Promise { - const topic = _topic[0] !== "?" ? - "?" + _topic : - _topic - - return new Promise((resolve, reject) => { - function errorHandler(reason: string) { - return function(originalError?: Error) { - /*reject(new Error(reason, { - cause: originalError - }))*/ - originalError ? - reject(new Error(reason + "\n Caused by: " + originalError)) : - reject(new Error(reason)) - socket.destroy() - } - } - - socket.on("error", errorHandler("Socket errored")) - socket.on("timeout", errorHandler("Connection timeout")) - - let byte = 0 - //type(2) + length(2) - const headerBuffer = Buffer.alloc(4) - let bodyBuffer: Buffer; - - function processResponse() { - const type = bodyBuffer[0]; - switch (type) { - case 0x00: { - return resolve(null) - } - case 0x2a: { - return resolve(bodyBuffer.readFloatLE(1)) - } - case 0x06: { - return resolve(bodyBuffer.subarray(0, -1).toString("utf-8")) - } - } - } - - - socket.on("data", data => { - //Still waiting on a complete header - if(byte < 4) { - const copiedBytes = data.copy(headerBuffer) - data = data.subarray(copiedBytes) - byte += copiedBytes - - //Got the full header! - if(byte >= 4) { - bodyBuffer = Buffer.alloc(headerBuffer.readUint16BE(2)) - //Sucks to be you, maybe you'll get a full header later? - } else { - return - } - } - - //We either just finished reading the header and got more to read, or we just got another body packet - if (data.length) { - const copiedBytes = data.copy(bodyBuffer) - byte += copiedBytes - } - - //expected buffer length + the header length - const fullLength = 4 + bodyBuffer.length - - //We got too many bytes! - if(byte > fullLength) { - errorHandler("Data is larger than expected") - return - } - - //No more data to read - if(byte === fullLength) { - socket.end() - processResponse() - } - }) - - socket.on("ready", () => { - const topicBuffer = Buffer.from(topic) - //type(2) + length(2) + padding(5) + msg(n) + terminator(1) - const dataBuffer = Buffer.alloc(2 + 2 + 5 + topicBuffer.length + 1) - let ptr; - - //Packet type 0x0083 - dataBuffer[0] = 0x00 - dataBuffer[1] = 0x83 - - //Write length of buffer - //padding(5) + msg(n) + terminator(1) - dataBuffer.writeUInt16BE(5 + topicBuffer.length + 1, 2) - - //Write padding - dataBuffer[4] = 0x00 - dataBuffer[5] = 0x00 - dataBuffer[6] = 0x00 - dataBuffer[7] = 0x00 - dataBuffer[8] = 0x00 - - //We're done with the header, we need to write the null terminated string to the 8th byte. - ptr = 9; - topicBuffer.copy(dataBuffer, ptr) - ptr += topicBuffer.length - dataBuffer[ptr] = 0x00 - - socket.write(dataBuffer) - - }) - }) -} - -export function sendTopic(config: SingleRunConfiguration): Promise { - const socket = createConnection({ - family: 4, - host: config.host, - port: config.port, - timeout: config.timeout - }) - return _sendTopic(socket, config.topic) -} - -export function createTopicConnection(config: SocketConfig): TopicConnection { - const socket = createConnection({ - family: 4, - host: config.host, - port: config.port - }) - return { - send: topic => { - if (socket.destroyed) { - return Promise.reject(new Error("Socket is destroyed")) - } - - return _sendTopic(socket, topic) - }, - destroy: () => { - socket.destroy() - } - } -} \ No newline at end of file +export * from "./http2byond" +export default Shim \ No newline at end of file diff --git a/src/shim.ts b/src/shim.ts new file mode 100644 index 0000000..94e0c64 --- /dev/null +++ b/src/shim.ts @@ -0,0 +1,26 @@ +import { deprecate } from 'util' +import { ShimForm } from './types' +import { sendTopic } from './http2byond' + +/** + * @deprecated Use {@link #sendTopic} and {@link #createTopicConnection} instead + */ +class ShimTopicConnection { + private timeout?: number; + + constructor(config?: { + timeout?: number + }) { + this.timeout = config?.timeout ?? 2000; + } + + run(config: ShimForm) { + return sendTopic({ + host: config.ip, + port: config.port, + topic: config.topic, + timeout: this.timeout + }); + } +} +export default deprecate(ShimTopicConnection, "The API for http2byond has changed. Please see updated documentation to see how to use the new sendTopic() and createTopicConnection() functions"); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..09a3ba8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,29 @@ +export interface SocketConfig { + //Domain name or IP to connect to + host: string; + //Port to connect to + port: number; +} + +export interface SingleRunConfiguration extends SocketConfig { + //URL params to send to BYOND + topic: string; + //Time to wait before aborting connection + timeout?: number; +} + +export type TopicReturnType = string | number | null + +export interface TopicConnection { + send: (topic: string) => Promise + destroy: () => void + queueLength: number + destroyed: boolean +} + + +export interface ShimForm { + ip: string; + port: number; + topic: string; +} \ No newline at end of file diff --git a/typings/http2byond.d.ts b/typings/http2byond.d.ts new file mode 100644 index 0000000..fb8a5dd --- /dev/null +++ b/typings/http2byond.d.ts @@ -0,0 +1,9 @@ +/// +import { Socket } from 'net'; +import { SingleRunConfiguration, SocketConfig, TopicConnection, TopicReturnType } from './types'; +/** + * @internal + */ +export declare function _sendTopic(socket: Socket, _topic: string): Promise; +export declare function sendTopic(config: SingleRunConfiguration): Promise; +export declare function createTopicConnection(config: SocketConfig): TopicConnection; diff --git a/typings/index.d.ts b/typings/index.d.ts index bd25c99..e27bac7 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,15 +1,3 @@ -export interface SocketConfig { - host: string; - port: number; -} -export interface SingleRunConfiguration extends SocketConfig { - topic: string; - timeout?: number; -} -export declare type TopicReturnType = string | number | null; -export interface TopicConnection { - send: (topic: string) => Promise; - destroy: () => void; -} -export declare function sendTopic(config: SingleRunConfiguration): Promise; -export declare function createTopicConnection(config: SocketConfig): TopicConnection; +import Shim from "./shim"; +export * from "./http2byond"; +export default Shim; diff --git a/typings/shim.d.ts b/typings/shim.d.ts new file mode 100644 index 0000000..d17b475 --- /dev/null +++ b/typings/shim.d.ts @@ -0,0 +1,13 @@ +import { ShimForm } from './types'; +/** + * @deprecated Use {@link #sendTopic} and {@link #createTopicConnection} instead + */ +declare class ShimTopicConnection { + private timeout?; + constructor(config?: { + timeout?: number; + }); + run(config: ShimForm): Promise; +} +declare const _default: typeof ShimTopicConnection; +export default _default; diff --git a/typings/types.d.ts b/typings/types.d.ts new file mode 100644 index 0000000..83925b3 --- /dev/null +++ b/typings/types.d.ts @@ -0,0 +1,20 @@ +export interface SocketConfig { + host: string; + port: number; +} +export interface SingleRunConfiguration extends SocketConfig { + topic: string; + timeout?: number; +} +export declare type TopicReturnType = string | number | null; +export interface TopicConnection { + send: (topic: string) => Promise; + destroy: () => void; + queueLength: number; + destroyed: boolean; +} +export interface ShimForm { + ip: string; + port: number; + topic: string; +}