@boem312/minecraft-server
Version:
A pure JS library to create Minecraft Java 1.16.3 servers
376 lines (299 loc) • 16.5 kB
JavaScript
const { versions } = require('../../functions/loader/data.js');
const { compareDotSeparatedStrings } = require('../../functions/compareDotSeparatedStrings.js');
const settings = require('../../settings.json');
//lazy load minecraft-protocol
let cachedMc;
const mc = () => {
if (cachedMc === undefined)
cachedMc = require('minecraft-protocol');
return cachedMc;
};
//lazy load image-size
let cachedImageSize;
const imageSize = () => {
if (cachedImageSize === undefined)
cachedImageSize = require('image-size');
return cachedImageSize;
};
const endianToggle = require('endian-toggle');
const path = require('path');
const Text = require('./Text.js');
const Client = require('../utils/Client.js');
const CustomError = require('../utils/CustomError.js');
const wait = ms => new Promise(res => setTimeout(res, ms));
const defaultPrivate = {
emit(name, ...args) {
for (const { callback } of this.p.events[name])
callback(...args);
this.p.events[name] = this.p.events[name].filter(({ once }) => once === false);
},
emitError(customError) {
if (this.p.events.error.length > 0)
this.p.emit('error', customError);
else
throw customError.toString();
}
};
const events = Object.freeze([
'listening',
'connect',
'join',
'leave',
'error'
]);
const _p = Symbol('_privates');
class Server {
constructor({ serverList, wrongVersionConnect, defaultClientProperties, proxy } = {}) {
if (compareDotSeparatedStrings(process.versions.node, settings.maxNodeVersion) > 0)
console.warn(`(minecraft-server) WARNING: Node.js version ${process.versions.node} is above the maximum supported version ${settings.maxNodeVersion}. Clients may be unable to join the server.`);
Object.defineProperty(this, _p, {
configurable: false,
enumerable: false,
writable: false,
value: {}
});
for (const [key, value] of Object.entries(defaultPrivate)) {
let newValue = value;
if (typeof newValue === 'function')
newValue = newValue.bind(this);
this[_p][key] = newValue;
};
this.serverList = serverList ?? (() => ({}));
this.wrongVersionConnect = wrongVersionConnect ??
(() => settings.defaults.serverList.wrongVersionConnectMessage.replace('{version}',
versions.find(a => a.legacy === false && a.protocol === settings.version)?.version ??
versions.find(a => a.legacy === true && a.protocol === settings.version)?.version ??
settings.version
));
this.defaultClientProperties = defaultClientProperties;
this.p.proxy = proxy;
this.clients = [];
this.p.events = Object.fromEntries(events.map(event => [event, []]));
this.p.clientInformation = new WeakMap();
this.p.server = mc().createServer({
encryption: true,
version: settings.version,
motd: settings.defaults.serverList.motd,
maxPlayers: settings.defaults.serverList.maxPlayers,
keepAlive: false,
hideErrors: true,
beforePing: (response, client) => {
let info = Object.assign({}, this.serverList({
...this.p.clientInformation.get(client).clientEarlyInformation,
version: //todo: give protocolVersion instead of versionName
versions.find(a => a.legacy === this.p.clientInformation.get(client).clientLegacyPing && a.protocol === this.p.clientInformation.get(client).clientEarlyInformation.version)?.version ??
versions.find(a => a.legacy === !this.p.clientInformation.get(client).clientLegacyPing && a.protocol === this.p.clientInformation.get(client).clientEarlyInformation.version)?.version,
legacy: this.p.clientInformation.get(client).clientLegacyPing //todo: add legacy to clientEarlyInformation?
}));
let infoVersionWrongText = `${info.version?.wrongText ?? info.version?.correct ?? versions.find(a => a.legacy === false && a.protocol === settings.version).version}`;
let infoVersionProtocol = info.version?.correct ? versions.find(a => a.legacy === false && a.version === info.version.correct).protocol : settings.version;
//todo: use applyDefaults?
if (!info) info = {};
if (!info.players) info.players = {};
if (info.players.max === undefined) info.players.max = settings.defaults.serverList.maxPlayers;
if (info.players.online === undefined) info.players.online = this.clients.length;
if (info.description === undefined) info.description = settings.defaults.serverList.motd;
if (info.description !== undefined && !(info.description instanceof Text))
info.description = new Text(info.description);
let playerHover = [];
if (info?.players?.hover === undefined)
playerHover = undefined;
else if (typeof info?.players?.hover === 'string')
playerHover = info.players.hover.split('\n').map(val => {
return { name: `${val}`, id: '00000000-0000-4000-8000-000000000000' }
})
else
for (const value of Object.values(info.players.hover))
playerHover.push({ name: value.name, id: value.uuid })
if (info.favicon) {
let imageInfo = imageSize()(info.favicon);
if (imageInfo.type !== 'png')
this.p.emitError(new CustomError('expectationNotMet', 'libraryUser', `image type in new ${this.constructor.name}({ serverList: () => ({ favicon: <typeof ${imageInfo.type}> }) }) `, {
got: imageInfo.type,
expectationType: 'value',
expectation: ['png']
}, this.constructor, { server: this }))
if (imageInfo.width !== 64 || imageInfo.height !== 64)
this.p.emitError(new CustomError('expectationNotMet', 'libraryUser', `image type in new ${this.constructor.name}({ serverList: () => ({ favicon: <dimensions of ${imageInfo.width}x${imageInfo.height}> }) }) `, {
got: `${imageInfo.width}x${imageInfo.height}`,
expectationType: 'value',
expectation: ['64x64']
}, this.constructor, { server: this }))
};
return {
version: {
name: infoVersionWrongText,
protocol: infoVersionProtocol
},
players: {
online: info.players.online,
max: info.players.max,
sample: playerHover
},
description: info.description.chat,
favicon: info.favicon ? `data:image/png;base64,${info.favicon.toString('base64')}` : undefined
};
}
});
this.p.server.on('listening', () => this.p.emit('listening'));
this.p.server.on('connection', client => {
this.p.clientInformation.set(client, {});
let clientState = null;
client.on('packet', ({ payload } = {}, { name, state } = {}, _, buffer) => {
if (
name === 'legacy_server_list_ping' &&
state === 'handshaking' &&
payload === 1
)
handleLegacyPing.call(this, buffer, client, this.serverList); //todo: check which versions are included in "legacy" and maybe add support for older serverlist versions?
});
client.on('state', state => { clientState = state });
client.on('set_protocol', ({ protocolVersion, serverHost, serverPort }) => {
const isLegacy = serverHost === '';
this.p.clientInformation.get(client).clientEarlyInformation = {
ip: client.socket.remoteAddress,
version: protocolVersion,
connection: {
host: isLegacy ? null : serverHost,
port: isLegacy ? null : serverPort
}
};
this.p.clientInformation.get(client).clientLegacyPing = false;
if ((clientState === 'login' && (this.p.clientInformation.get(client).clientEarlyInformation.version !== settings.version) || isLegacy)) { //todo: is it (clientState && version) || isLegacy or clientState && (version || isLegacy)
let endReason = this.wrongVersionConnect({ ...this.p.clientInformation.get(client).clientEarlyInformation, legacy: isLegacy });
if (typeof endReason === 'string')
if (isLegacy) {
const buffer = Buffer.alloc(2);
buffer.writeUInt16BE(endReason.length);
const responseBuffer = Buffer.concat([Buffer.from('ff', 'hex'), buffer, endianToggle(Buffer.from(endReason, 'utf16le'), 16)])
return client.socket.write(responseBuffer)
} else
client.end(endReason)
else if (endReason !== null)
this.p.emitError(new CustomError('expectationNotMet', 'libraryUser', `endReason in new ${this.constructor.name}({ wrongVersionConnect: () => endReason }) `, {
got: endReason,
expectationType: 'type',
expectation: 'string | null'
}, this.constructor, { server: this }))
};
});
});
this.p.server.on('login', client => {
new Client(client, this, this.p.clientInformation.get(client).clientEarlyInformation, this.defaultClientProperties);
});
}
get p() {
let callPath = new Error().stack.split('\n')[2];
if (callPath.includes('('))
callPath = callPath.split('(')[1].split(')')[0];
else
callPath = callPath.split('at ')[1];
callPath = callPath.split(':').slice(0, 2).join(':');
let folderPath = path.resolve(__dirname, '../../');
if (!callPath.startsWith(folderPath))
console.warn('(minecraft-server) WARNING: Detected access to private properties from outside of the module. This is not recommended and may cause unexpected behavior.');
return this[_p];
}
set p(value) {
console.error('(minecraft-server) ERROR: Setting private properties is not supported. Action ignored.');
}
joinProxyClient(proxyClient) {
new Client(proxyClient.client, this, proxyClient.earlyInformation, this.defaultClientProperties);
}
on(event, callback) {
if (!events.includes(event))
this.p.emitError(new CustomError('expectationNotMet', 'libraryUser', `event in <${this.constructor.name}>.on(${require('util').inspect(event)}, ...)`, {
got: event,
expectationType: 'value',
expectation: events
}, this.on, { server: this }))
this.p.events[event].push({ callback, once: false })
}
once(event, callback) {
if (!events.includes(event))
this.p.emitError(new CustomError('expectationNotMet', 'libraryUser', `event in <${this.constructor.name}>.once(${require('util').inspect(event)}, ...)`, {
got: event,
expectationType: 'value',
expectation: events
}, this.on, { server: this }))
this.p.events[event].push({ callback, once: true })
}
async close() {
await wait(500); //to avoid weird bugs
for (const client of this.clients) client.p.shutdown();
this.p.server.close();
}
}
function hexToString(hex) {
let out = '';
for (let ii = 0; ii < hex.length; ii += 2)
out += String.fromCharCode(parseInt(hex.substr(ii, 2), 16));
return out.split('\x00').join('');
}
function hexToNumber(hex) {
return parseInt(hex, 16);
}
function handleLegacyPing(request, client, serverList) {
respondToLegacyPing.call(this, parseLegacyPing(request), client, serverList);
}
function respondToLegacyPing({ protocol, hostname, port }, client, serverList) {
this.p.clientInformation.get(client).clientEarlyInformation = {
ip: client.socket.remoteAddress,
version: protocol ?? null,
connection: {
host: hostname,
port
}
}
this.p.clientInformation.get(client).clientLegacyPing = true
let info = serverList({
...this.p.clientInformation.get(client).clientEarlyInformation,
version: //todo: give protocolVersion instead of versionName
versions.find(a => a.legacy === this.p.clientInformation.get(client).clientLegacyPing && a.protocol === this.p.clientInformation.get(client).clientEarlyInformation.version)?.version ||
versions.find(a => a.legacy === !this.p.clientInformation.get(client).clientLegacyPing && a.protocol === this.p.clientInformation.get(client).clientEarlyInformation.version)?.version,
legacy: this.p.clientInformation.get(client).clientLegacyPing //todo: add legacy to clientEarlyInformation?
});
if (!info) info = {};
if (!info.players) info.players = {};
if (info.players.max === undefined) info.players.max = settings.defaults.serverList.maxPlayers;
if (info.players.online === undefined) info.players.online = this.clients.length;
if (info.description === undefined) info.description = settings.defaults.serverList.motd;
if (!(info.description instanceof Text)) info.description = new Text(info.description);
if (!info.version) info.version = {};
if (!info.version.correct) info.version.correct = settings.version;
const responseString = '\xa7' + [
1,
parseInt(versions.find(a => a.legacy === true && a.version === info.version.correct)?.protocol ?? 127),
`${info.version?.wrongText ?? info.version.correct}`,
info.description.string.split('\n')[0], // legacy version only supports one line
`${info.players.online}`,
`${info.players.max}`
].join('\0');
const buffer = Buffer.alloc(2);
buffer.writeUInt16BE(responseString.length);
const responseBuffer = Buffer.concat([Buffer.from('ff', 'hex'), buffer, endianToggle(Buffer.from(responseString, 'utf16le'), 16)])
client.socket.write(responseBuffer)
}
function parseLegacyPing(requestLeft) {
requestLeft = requestLeft.toString('hex').split('');
let request = [];
/* 0 */ request.push(requestLeft.splice(0, 2).join('')) //fe
/* 1 */ request.push(hexToNumber(requestLeft.splice(0, requestLeft.join('').indexOf('fa')).join(''))) //payload //todo: "payload" is unclear, what does this mean? //todo: what does this line do?
/* 2 */ request.push(requestLeft.splice(0, 2).join('')) //fa
//todo: add checks to see if sent information is what we expect
if (requestLeft.length === 0) return { protocol: null, hostname: null, port: null } //The client didn't send any information
/* 3 */ request.push(hexToNumber(requestLeft.splice(0, 4).join(''))) //000b (=11) length of following string
/* 4 */ request.push(hexToString(requestLeft.splice(0, request[3] * 4).join(''))) // MC|PingHost
/* 5 */ request.push(hexToNumber(requestLeft.splice(0, 4).join(''))) //length of rest of request
/* 6 */ request.push(hexToNumber(requestLeft.splice(0, 2).join(''))) //protocol version
/* 7 */ request.push(hexToNumber(requestLeft.splice(0, 4).join(''))) //length of following string
/* 8 */ request.push(hexToString(requestLeft.splice(0, request[7] * 4).join(''))) //hostname
/* 9 */ request.push(hexToNumber(requestLeft.splice(0, 8).join(''))) //port
//todo: add checks to see if sent information is what we expect
return {
protocol: request[6],
hostname: request[8],
port: request[9]
}
}
module.exports = Server;