diff --git a/.npmignore b/.npmignore
index b512c09..a085593 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1 +1,7 @@
-node_modules
\ No newline at end of file
+node_modules/
+npm-debug.log
+__*
+src/**/*.json
+# Runtime generated data
+data/**/sample
+tools/bds*
\ No newline at end of file
diff --git a/README.md b/README.md
index 3773432..02c816a 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,122 @@
-# bedrock-protocol
+# Minecraft Bedrock protocol library
[](http://npmjs.com/package/bedrock-protocol)
[](https://github.com/PrismarineJS/bedrock-protocol/actions?query=workflow%3A%22CI%22)
[](https://discord.gg/GsEFRM8)
[](https://gitpod.io/#https://github.com/PrismarineJS/bedrock-protocol)
-Not ready for prime time yet, check https://github.com/PrismarineJS/bedrock-protocol/projects/1 for progress
-Parse and serialize Minecraft: Pocket Edition packets
+Minecraft Bedrock Edition (aka MCPE) protocol library, supporting authentication and encryption. Help [contribute](CONTRIBUTING.md).
+
+This is a work in progress. You can track the progress in https://github.com/PrismarineJS/bedrock-protocol/pull/34.
## Features
- * Supports Minecraft Pocket Edition `1.0`
- * Pure JavaScript
- * Easily send and listen for any packet
- * RakNet support through [node-raknet](https://github.com/mhsjlw/node-raknet)
+ - Supports Minecraft Bedrock version 1.16.201, 1.16.210, 1.16.220
+ - Parses and serialize all packets with JavaScript objects
+ - Send a packet by supplying fields as a JavaScript object
+ - Automatically respond to keep-alive packets
+ - Client
+ - Authentication and login
+ - Encryption
+ - Online mode servers
+ - [Ping a server for status](docs/API.md#beping-host-port---serveradvertisement)
+ - Server
+ - Autheticate clients with Xbox Live
+ - Ping status
+ - Automatically respond to keep-alive packets
+
+ * Robust test coverage.
+ * Easily extend with many other PrismarineJS projects, world providers, and more
+ * Optimized for rapidly staying up to date with Minecraft protocol updates.
+
+
+Want to contribute on something important for PrismarineJS ? go to https://github.com/PrismarineJS/mineflayer/wiki/Big-Prismarine-projects
## Installation
-Simply run
- npm install bedrock-protocol
+`npm install bedrock-protocol`
-Then view our `examples` for inspiration!
+## Usage
-## Contributors
-This project is run by these guys:
+### Client example
- - [mhsjlw](https://github.com/mhsjlw)
- - [rom1504](https://github.com/rom1504)
- - [Filiph Sandström](https://github.com/filfat)
+```js
+const bedrock = require('bedrock-protocol')
+const client = bedrock.createClient({
+ host: 'localhost', // optional
+ port: 19132, // optional, default 19132
+ username: 'Notch', // the username you want to join as, optional if online mode
+ offline: true // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true.
+})
-## License
-Licensed under the MIT license.
+client.on('text', (packet) => { // Listen for chat messages and echo them back.
+ if (packet.source_name != client.options.username) {
+ client.queue('text', {
+ type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '',
+ message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}`
+ })
+ }
+})
+```
+
+### Server example
+
+*Can't connect locally on Windows? See the [faq](docs/FAQ.md)*
+```js
+const bedrock = require('bedrock-protocol')
+const server = new bedrock.createServer({
+ host: '0.0.0.0', // optional. Hostname to bind as.
+ port: 19132, // optional
+ version: '1.16.220' // optional. The server version, latest if not specified.
+})
+
+server.on('connect', client => {
+ client.on('join', () => { // The client has joined the server.
+ const d = new Date() // Once client is in the server, send a colorful kick message
+ client.disconnect(`Good ${d.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'} :)\n\nMy time is ${d.toLocaleString()} !`)
+ })
+})
+```
+
+### Ping example
+
+```js
+const { ping } = require('bedrock-protocol')
+ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => {
+ console.log(res)
+})
+```
+
+## Documentation
+
+See [API documentation](docs/API.md)
+
+See [faq](docs/FAQ.md)
+
+
+
+## Testing
+
+```npm test```
+
+## Debugging
+
+You can enable some protocol debugging output using `DEBUG` environment variable.
+
+Through node.js, add `process.env.DEBUG = 'minecraft-protocol'` at the top of your script.
+
+## Contribute
+
+Please read [CONTRIBUTING.md](CONTRIBUTING.md) and https://github.com/PrismarineJS/prismarine-contribute
+
+## History
+
+See [history](HISTORY.md)
+
+
\ No newline at end of file
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..dac0dab
--- /dev/null
+++ b/docs/API.md
@@ -0,0 +1,115 @@
+# Documentation
+
+## be.createClient(options) : Client
+
+Returns a `Client` instance and connects to the server.
+
+`options` is an object containing the properties :
+
+| Paramater | Optionality | Description |
+| ----------- | ----------- |-|
+| host | **Required** | Hostname to connect to, for example `127.0.0.1`. |
+| port | *optional* | port to connect to, default to **19132** |
+| version | *optional* | Version to connect as.
(Future feature, see [#69][1]) If not specified, should automatically match server version.
(Current feature) Defaults to latest version. |
+| offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth. |
+| username | Conditional | Required if `offline` set to true : Username to connect to server as. |
+| connectTimeout | *optional* | default to **9000ms**. How long to wait in milliseconds while trying to connect to server. |
+| onMsaCode | *optional* | Callback called when signing in with a microsoft account with device code auth, `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) |
+| autoInitPlayer | optional | default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn. |
+
+
+## be.createServer(options) : Server
+
+Returns a `Server` instance and starts listening for clients. All clients will be
+authenticated unless offline is set to true.
+
+`options` is an object containing the properties :
+
+| Paramater | Optionality | Description |
+| ----------- | ----------- |-|
+| host | **Required** | The hostname to bind to. use `0.0.0.0` to bind all IPv4 addresses. |
+| port | *optional* | the port to bind to, default **19132** |
+| version | *optional* | Version to run server as. Clients below this version will be kicked, clients above will still be permitted. |
+| offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth enforcement. |
+| maxPlayers | *[Future][1]* | default to **3**. Set this to change the maximum number of players connected. |
+| kickTimeout | *[Future][1]* | How long to wait before kicking a unresponsive client. |
+| motd | *[Future][1]* | ServerAdvertisement instance. The server advertisment shown to clients, including the message of the day, level name. |
+| advertismentFn | *[Future][1]* | optional. Custom function to call that should return a ServerAdvertisement, used for setting the RakNet server PONG data. Overrides `motd`. |
+
+## be.ping({ host, port }) : ServerAdvertisement
+
+Ping a server and get the response. See type definitions for the structure.
+
+## Methods
+
+[See the type defintions for this library for more information on methods.](../index.d.ts)
+
+Both Client and Server classes have `write(name, params)` and `queue(name, params)` methods. The former sends a packet immediately, and the latter queues them to be sent in the next packet batch. Prefer the latter for better performance and less blocking.
+
+You can use `.close()` to terminate a connection, and `.disconnect(reason)` to gracefully kick a connected client.
+
+## Server usage
+
+You can create a server as such:
+```js
+const bedrock = require('bedrock-protocol')
+const server = bedrock.createServer({
+ host: '0.0.0.0', // the hostname to bind to, use '0.0.0.0' to bind all hostnames
+ port: 19132, // optional, port to bind to, default 19132
+ offline: false // default false. verify connections with XBL
+})
+```
+
+Then you can listen for clients and their events:
+```js
+// The 'connect' event is emitted after a new client has started a connection with the server and is handshaking.
+// Its one paramater is the client class instance which handles this session from here on out.
+server.on('connect', (client) => {
+ // 'join' is emitted after the client has authenticated & connection is now encrypted.
+ client.on('join', () => {
+ // Then we can continue with the server spawning sequence. See examples/serverTest.js for an example spawn sequence.
+ })
+})
+
+```
+
+Order of server client event emisions:
+* 'connect' - emitted by `Server` after a client first joins the server. Second paramater is a `ServerPlayer` instance.
+* 'login' - emitted by client after the client has been authenticated by the server
+* 'join' - the client is ready to recieve game packets after successful server-client handshake/encryption
+* 'spawn' - emitted after the client lets the server know that it has successfully spawned
+
+## Client docs
+
+You can create a server as such:
+```js
+const bedrock = require('bedrock-protocol')
+const client = bedrock.createClient({
+ host: '127.0.0.1', // the hostname to bind to, use '0.0.0.0' to bind all hostnames
+ port: 19132, // optional, port to bind to, default 19132
+ username: 'Notch' // Any profile name, only used internally for account caching. You'll
+ // be asked to sign-in with Xbox Live the first time.
+})
+```
+
+```js
+// The 'join' event is emitted after the player has authenticated
+// and is ready to recieve chunks and start game packets
+client.on('join', client => console.log('Player has joined!'))
+
+// The 'spawn' event is emitted. The chunks have been sent and all is well.
+client.on('join', client => console.log('Player has spawned!'))
+
+// We can listen for text packets. See proto.yml for documentation.
+client.on('text', (packet) => {
+ console.log('Client got text packet', packet)
+})
+```
+
+Order of client event emisions:
+* 'connect' - emitted after a client first joins the server
+* 'login' - emitted after the client has been authenticated by the server
+* 'join' - the client is ready to recieve game packets after successful server-client handshake
+* 'spawn' - emitted after the client has permission from the server to spawn
+
+[1]: https://github.com/PrismarineJS/bedrock-protocol/issues/69
diff --git a/docs/FAQ.md b/docs/FAQ.md
new file mode 100644
index 0000000..c4a5f06
--- /dev/null
+++ b/docs/FAQ.md
@@ -0,0 +1,7 @@
+## Can’t connect to localhost Win10 server with Minecraft Win10 Edition
+
+This issue occurs due to loopback restrictions on Windows 10 UWP apps. To lift this restriction, launch Windows PowerShell as an administrator and run the following:
+
+```ps
+CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe"
+```
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
deleted file mode 100644
index f5f6564..0000000
--- a/docs/api.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Documentation
-
-## be.createClient(options)
-
-Returns a `Client` instance and starts listening. All clients will be
-automatically logged in and validated against microsoft's auth.
-
-`options` is an object containing the properties :
- * host : default to undefined which means listen to all available ipv4 and ipv6 adresses
- * port (optional) : default to 25565
- (see https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback for details)
- * kickTimeout (optional) : default to `10*1000` (10s), kick client that doesn't answer to keepalive after that time
- * version (optional) : default to latest stable version, version of server
- * autoInitPlayer (optional) : default to true, If we should send SetPlayerInitialized to the server after getting play_status spawn.
- * offline (optional) : default to false, whether to auth with microsoft
- * connectTimeout (optional) : default to 9000, ms to wait before aborting connection attempt
diff --git a/examples/clientReadmeExample.js b/examples/clientReadmeExample.js
new file mode 100644
index 0000000..32cffaf
--- /dev/null
+++ b/examples/clientReadmeExample.js
@@ -0,0 +1,17 @@
+/* eslint-disable */
+const bedrock = require('bedrock-protocol')
+const client = bedrock.createClient({
+ host: 'localhost', // optional
+ port: 19132, // optional, default 19132
+ username: 'Notch', // the username you want to join as, optional if online mode
+ offline: false // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true.
+})
+
+client.on('text', (packet) => { // Listen for chat messages and echo them back.
+ if (packet.source_name != client.options.username) {
+ client.queue('text', {
+ type: 'chat', needs_translation: false, source_name: client.username, xuid: '', platform_chat_id: '',
+ message: `${packet.source_name} said: ${packet.message} on ${new Date().toLocaleString()}`
+ })
+ }
+})
\ No newline at end of file
diff --git a/examples/clientTest.js b/examples/clientTest.js
index bcb644e..717a428 100644
--- a/examples/clientTest.js
+++ b/examples/clientTest.js
@@ -9,6 +9,7 @@ async function test () {
// You can specify version by adding :
// version: '1.16.210'
})
+ client.connect()
client.once('resource_packs_info', (packet) => {
client.write('resource_pack_client_response', {
diff --git a/examples/ping.js b/examples/ping.js
new file mode 100644
index 0000000..5545cd5
--- /dev/null
+++ b/examples/ping.js
@@ -0,0 +1,5 @@
+const { ping } = require('bedrock-protocol')
+
+ping({ host: 'play.cubecraft.net', port: 19132 }).then(res => {
+ console.log(res)
+})
diff --git a/examples/serverReadmeExample.js b/examples/serverReadmeExample.js
new file mode 100644
index 0000000..95f4925
--- /dev/null
+++ b/examples/serverReadmeExample.js
@@ -0,0 +1,14 @@
+/* eslint-disable */
+const bedrock = require('bedrock-protocol')
+const server = new bedrock.createServer({
+ host: '0.0.0.0', // optional
+ port: 19132, // optional
+ version: '1.16.220' // The server version
+})
+
+server.on('connect', client => {
+ client.on('join', () => { // The client has joined the server.
+ const d = new Date() // Once client is in the server, send a colorful kick message
+ client.disconnect(`Good ${d.getHours() < 12 ? '§emorning§r' : '§3afternoon§r'} :)\n\nMy time is ${d.toLocaleString()} !`)
+ })
+})
\ No newline at end of file
diff --git a/examples/serverTest.js b/examples/serverTest.js
index c060954..a704dfd 100644
--- a/examples/serverTest.js
+++ b/examples/serverTest.js
@@ -71,25 +71,25 @@ async function startServer (version = '1.16.210', ok) {
client.queue('inventory_slot', { window_id: 120, slot: 0, item: new Item().toBedrock() })
}
- client.write('player_list', get('player_list'))
- client.write('start_game', get('start_game'))
- client.write('item_component', { entries: [] })
- client.write('set_spawn_position', get('set_spawn_position'))
- client.write('set_time', { time: 5433771 })
- client.write('set_difficulty', { difficulty: 1 })
- client.write('set_commands_enabled', { enabled: true })
- client.write('adventure_settings', get('adventure_settings'))
- client.write('biome_definition_list', get('biome_definition_list'))
- client.write('available_entity_identifiers', get('available_entity_identifiers'))
- client.write('update_attributes', get('update_attributes'))
- client.write('creative_content', get('creative_content'))
- client.write('inventory_content', get('inventory_content'))
- client.write('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true })
- client.write('crafting_data', get('crafting_data'))
- client.write('available_commands', get('available_commands'))
- client.write('chunk_radius_update', { chunk_radius: 1 })
- client.write('game_rules_changed', get('game_rules_changed'))
- client.write('respawn', get('respawn'))
+ client.queue('player_list', get('player_list'))
+ client.queue('start_game', get('start_game'))
+ client.queue('item_component', { entries: [] })
+ client.queue('set_spawn_position', get('set_spawn_position'))
+ client.queue('set_time', { time: 5433771 })
+ client.queue('set_difficulty', { difficulty: 1 })
+ client.queue('set_commands_enabled', { enabled: true })
+ client.queue('adventure_settings', get('adventure_settings'))
+ client.queue('biome_definition_list', get('biome_definition_list'))
+ client.queue('available_entity_identifiers', get('available_entity_identifiers'))
+ client.queue('update_attributes', get('update_attributes'))
+ client.queue('creative_content', get('creative_content'))
+ client.queue('inventory_content', get('inventory_content'))
+ client.queue('player_hotbar', { selected_slot: 3, window_id: 'inventory', select_slot: true })
+ client.queue('crafting_data', get('crafting_data'))
+ client.queue('available_commands', get('available_commands'))
+ client.queue('chunk_radius_update', { chunk_radius: 1 })
+ client.queue('game_rules_changed', get('game_rules_changed'))
+ client.queue('respawn', get('respawn'))
for (const chunk of chunks) {
client.queue('level_chunk', chunk)
diff --git a/index.d.ts b/index.d.ts
new file mode 100644
index 0000000..705e2ad
--- /dev/null
+++ b/index.d.ts
@@ -0,0 +1,111 @@
+import EventEmitter from "events"
+
+declare module "bedrock-protocol" {
+ type Version = '1.16.220' | '1.16.210' | '1.16.201'
+
+ export interface Options {
+ // The string version to start the client or server as
+ version: number,
+ // For the client, the hostname of the server to connect to (default: 127.0.0.1)
+ // For the server, the hostname to bind to (default: 0.0.0.0)
+ host: string,
+ // The port to connect or bind to, default: 19132
+ port: number
+ }
+
+ enum ClientStatus {
+ Disconected, Authenticating, Initializing, Initialized
+ }
+
+ export class Connection extends EventEmitter {
+ readonly status: ClientStatus
+
+ // Check if the passed version is less than or greater than the current connected client version.
+ versionLessThan(version: string | number)
+ versionGreaterThan(version: string | number)
+
+ // Writes a Minecraft bedrock packet and sends it without queue batching
+ write(name: string, params: object)
+ // Adds a Minecraft bedrock packet to be sent in the next outgoing batch
+ queue(name: string, params: object)
+ // Writes a MCPE buffer to the connection and skips Protodef serialization. `immediate` if skip queue.
+ sendBuffer(buffer: Buffer, immediate?: boolean)
+ }
+
+ type PlayStatus =
+ | 'login_success'
+ // # Displays "Could not connect: Outdated client!"
+ | 'failed_client'
+ // # Displays "Could not connect: Outdated server!"
+ | 'failed_spawn'
+ // # Sent after world data to spawn the player
+ | 'player_spawn'
+ // # Displays "Unable to connect to world. Your school does not have access to this server."
+ | 'failed_invalid_tenant'
+ // # Displays "The server is not running Minecraft: Education Edition. Failed to connect."
+ | 'failed_vanilla_edu'
+ // # Displays "The server is running an incompatible edition of Minecraft. Failed to connect."
+ | 'failed_edu_vanilla'
+ // # Displays "Wow this server is popular! Check back later to see if space opens up. Server Full"
+ | 'failed_server_full'
+
+
+ export class Client extends Connection {
+ constructor(options: Options)
+ // The client's EntityID returned by the server
+ readonly entityId: BigInt
+
+ /**
+ * Close the connection, leave the server.
+ */
+ close()
+ }
+
+ /**
+ * `Player` represents a player connected to the server.
+ */
+ export class Player extends Connection {
+ /**
+ * Disconnects a client before it has logged in via a PlayStatus packet.
+ * @param {string} playStatus
+ */
+ sendDisconnectStatus(playStatus: PlayStatus)
+
+ /**
+ * Disconnects a client
+ * @param reason The message to be shown to the user on disconnect
+ * @param hide Don't show the client the reason for the disconnect
+ */
+ disconnect(reason: string, hide?: boolean)
+
+ /**
+ * Close the connection. Already called by disconnect. Call this to manually close RakNet connection.
+ */
+ close()
+ }
+
+ export class Server extends EventEmitter {
+ clients: Map
+ constructor(options: Options)
+ // Disconnects all currently connected clients
+ close(disconnectReason: string)
+ }
+
+ class ServerAdvertisement {
+ motd: string
+ name: string
+ protocol: number
+ version: string
+ players: {
+ online: number,
+ max: number
+ }
+ gamemode: string
+ serverId: string
+ }
+
+ export function createClient(options: Options): Client
+ export function createServer(options: Options): Server
+
+ export function ping({ host, port }) : ServerAdvertisement
+}
\ No newline at end of file
diff --git a/index.js b/index.js
index d6ba9b1..834f737 100644
--- a/index.js
+++ b/index.js
@@ -3,5 +3,6 @@ module.exports = {
...require('./src/server'),
...require('./src/serverPlayer'),
...require('./src/relay'),
- ...require('./src/createClient')
+ ...require('./src/createClient'),
+ ...require('./src/createServer')
}
diff --git a/src/auth/login.js b/src/auth/login.js
index 49913b4..d835326 100644
--- a/src/auth/login.js
+++ b/src/auth/login.js
@@ -65,7 +65,7 @@ module.exports = (client, server, options) => {
// inside of PlayFab.
PlayFabId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0', // 1.16.210
PremiumSkin: false,
- SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343702',
+ SelfSignedId: nextUUID(),
ServerAddress: `${options.hostname}:${options.port}`,
SkinAnimationData: '',
SkinColor: '#ffffcd96',
diff --git a/src/auth/loginVerify.js b/src/auth/loginVerify.js
index 0809269..233e729 100644
--- a/src/auth/loginVerify.js
+++ b/src/auth/loginVerify.js
@@ -14,7 +14,7 @@ module.exports = (client, server, options) => {
// from Xbox with addition user profile data
// We verify that at least one of the tokens in the chain has been properly
// signed by Mojang by checking the x509 public key in the JWT headers
- // let didVerify = false
+ let didVerify = false
let pubKey = mcPubKeyToPem(getX5U(chain[0])) // the first one is client signed, allow it
let finalKey = null
@@ -26,17 +26,20 @@ module.exports = (client, server, options) => {
// Check if signed by Mojang key
const x5u = getX5U(token)
if (x5u === constants.PUBLIC_KEY && !data.extraData?.XUID) {
- // didVerify = true
+ didVerify = true
debug('Verified client with mojang key', x5u)
}
- // TODO: Handle `didVerify` = false
pubKey = decoded.identityPublicKey ? mcPubKeyToPem(decoded.identityPublicKey) : x5u
finalKey = decoded.identityPublicKey || finalKey // non pem
data = { ...data, ...decoded }
}
// console.log('Result', data)
+ if (!didVerify && !options.offline) {
+ client.disconnect('disconnectionScreen.notAuthenticated')
+ }
+
return { key: finalKey, data }
}
diff --git a/src/client.js b/src/client.js
index 2fb3ce1..83dcb7c 100644
--- a/src/client.js
+++ b/src/client.js
@@ -29,25 +29,30 @@ class Client extends Connection {
Login(this, null, this.options)
LoginVerify(this, null, this.options)
- this.on('session', this.connect)
-
- if (options.offline) {
- console.debug('offline mode, not authenticating', this.options)
- auth.createOfflineSession(this, this.options)
- } else if (options.password) {
- auth.authenticatePassword(this, this.options)
- } else {
- auth.authenticateDeviceCode(this, this.options)
- }
+ const hostname = this.options.hostname
+ const port = this.options.port
+ this.connection = new RakClient({ useWorkers: true, hostname, port })
this.startGameData = {}
this.clientRuntimeId = null
- this.startQueue()
this.inLog = (...args) => debug('C ->', ...args)
this.outLog = (...args) => debug('C <-', ...args)
}
+ connect () {
+ this.on('session', this._connect)
+
+ if (this.options.offline) {
+ console.debug('offline mode, not authenticating', this.options)
+ auth.createOfflineSession(this, this.options)
+ } else {
+ auth.authenticateDeviceCode(this, this.options)
+ }
+
+ this.startQueue()
+ }
+
validateOptions () {
if (!this.options.hostname || this.options.port == null) throw Error('Invalid hostname/port')
@@ -70,18 +75,23 @@ class Client extends Connection {
this.handle(buffer)
}
- connect = async (sessionData) => {
- const hostname = this.options.hostname
- const port = this.options.port
- debug('[client] connecting to', hostname, port, sessionData)
+ async ping () {
+ try {
+ return await this.connection.ping(this.options.connectTimeout)
+ } catch (e) {
+ console.warn(`Unable to connect to [${this.options.hostname}]/${this.options.port}. Is the server running?`)
+ throw e
+ }
+ }
- this.connection = new RakClient({ useWorkers: true, hostname, port })
+ _connect = async (sessionData) => {
+ debug('[client] connecting to', this.options.hostname, this.options.port, sessionData, this.connection)
this.connection.onConnected = () => this.sendLogin()
this.connection.onCloseConnection = () => this.close()
this.connection.onEncapsulated = this.onEncapsulated
this.connection.connect()
- this.connectTimer = setTimeout(() => {
+ this.connectTimeout = setTimeout(() => {
if (this.status === ClientStatus.Disconnected) {
this.connection.close()
this.emit('error', 'connect timed out')
@@ -115,6 +125,7 @@ class Client extends Connection {
onDisconnectRequest (packet) {
console.warn(`C Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`)
this.emit('kick', packet)
+ this.close()
}
onPlayStatus (statusPacket) {
@@ -128,21 +139,24 @@ class Client extends Connection {
}
close () {
- this.emit('close')
+ if (this.status !== ClientStatus.Disconnected) {
+ this.emit('close') // Emit close once
+ console.log('Client closed!')
+ }
clearInterval(this.loop)
clearTimeout(this.connectTimeout)
this.q = []
this.q2 = []
this.connection?.close()
this.removeAllListeners()
- console.log('Client closed!')
+ this.status = ClientStatus.Disconnected
}
tryRencode (name, params, actual) {
const packet = this.serializer.createPacketBuffer({ name, params })
- console.assert(packet.toString('hex') === actual.toString('hex'))
- if (packet.toString('hex') !== actual.toString('hex')) {
+ console.assert(packet.equals(actual))
+ if (!packet.equals(actual)) {
const ours = packet.toString('hex').match(/.{1,16}/g).join('\n')
const theirs = actual.toString('hex').match(/.{1,16}/g).join('\n')
diff --git a/src/connection.js b/src/connection.js
index a13872a..0b0a28c 100644
--- a/src/connection.js
+++ b/src/connection.js
@@ -75,7 +75,7 @@ class Connection extends EventEmitter {
if (this.q.length) {
// TODO: can we just build Batch before the queue loop?
const batch = new BatchPacket()
- this.outLog('<- BATCH', this.q2)
+ this.outLog('<- Batch', this.q2)
const sending = []
for (let i = 0; i < this.q.length; i++) {
const packet = this.q.shift()
diff --git a/src/createClient.js b/src/createClient.js
index 612fd48..e357627 100644
--- a/src/createClient.js
+++ b/src/createClient.js
@@ -1,45 +1,76 @@
const { Client } = require('./client')
+const { RakClient } = require('./rak')
const assert = require('assert')
+const advertisement = require('./server/advertisement')
-module.exports = { createClient }
-
-/** @param {{ version?: number, hostname: string, port?: number }} options */
+/** @param {{ version?: number, hostname: string, port?: number, connectTimeout?: number }} options */
function createClient (options) {
- assert(options && options.hostname)
+ assert(options)
+ if (options.host) options.hostname = options.host
const client = new Client({ port: 19132, ...options })
- client.once('resource_packs_info', (packet) => {
- handleResourcePackInfo(client)
- disableClientCache(client)
- handleRenderDistance(client)
- handleTickSync(client)
- })
+ if (options.skipPing) {
+ connect(client)
+ } else { // Try to ping
+ client.ping().then(data => {
+ const advert = advertisement.fromServerName(data)
+ console.log(`Connecting to server ${advert.motd} (${advert.name}), version ${advert.version}`)
+ // TODO: update connect version based on ping response
+ connect(client)
+ }, client)
+ }
return client
}
-function handleResourcePackInfo (client) {
- client.write('resource_pack_client_response', {
- response_status: 'completed',
- resourcepackids: []
- })
+function connect (client) {
+ // Actually connect
+ client.connect()
- client.once('resource_pack_stack', (stack) => {
+ client.once('resource_packs_info', (packet) => {
client.write('resource_pack_client_response', {
response_status: 'completed',
resourcepackids: []
})
+
+ client.once('resource_pack_stack', (stack) => {
+ client.write('resource_pack_client_response', {
+ response_status: 'completed',
+ resourcepackids: []
+ })
+ })
+
+ client.queue('client_cache_status', { enabled: false })
+ client.queue('request_chunk_radius', { chunk_radius: client.renderDistance || 1 })
+ client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n })
+ })
+
+ const KEEPALIVE_INTERVAL = 10 // Send tick sync packets every 10 ticks
+ let keepalive
+ client.tick = 0n
+ client.once('spawn', () => {
+ keepalive = setInterval(() => {
+ // Client fills out the request_time and the server does response_time in its reply.
+ client.queue('tick_sync', { request_time: client.tick, response_time: 0n })
+ client.tick += BigInt(KEEPALIVE_INTERVAL)
+ }, 50 * KEEPALIVE_INTERVAL)
+
+ client.on('tick_sync', async packet => {
+ client.emit('heartbeat', packet.response_time)
+ client.tick = packet.response_time
+ })
+ })
+
+ client.once('close', () => {
+ clearInterval(keepalive)
})
}
-function handleRenderDistance (client) {
- client.queue('request_chunk_radius', { chunk_radius: 1 })
+async function ping ({ host, port }) {
+ const con = new RakClient({ hostname: host, port })
+ const ret = await con.ping()
+ con.close()
+ return advertisement.fromServerName(ret)
}
-function disableClientCache (client) {
- client.queue('client_cache_status', { enabled: false })
-}
-
-function handleTickSync (client) {
- client.queue('tick_sync', { request_time: BigInt(Date.now()), response_time: 0n })
-}
+module.exports = { createClient, ping }
diff --git a/src/createServer.js b/src/createServer.js
new file mode 100644
index 0000000..3c6762c
--- /dev/null
+++ b/src/createServer.js
@@ -0,0 +1,11 @@
+const { Server } = require('./server')
+
+function createServer (options) {
+ if (options.host) options.hostname = options.host
+ if (!options.port) options.port = 19132
+ const server = new Server(options)
+ server.listen()
+ return server
+}
+
+module.exports = { createServer }
diff --git a/src/rak.js b/src/rak.js
index 912df2d..7d922c7 100644
--- a/src/rak.js
+++ b/src/rak.js
@@ -36,7 +36,7 @@ class RakNativeClient extends EventEmitter {
})
}
- async ping () {
+ async ping (timeout = 1000) {
this.raknet.ping()
return waitFor((done) => {
this.raknet.on('pong', (ret) => {
@@ -44,7 +44,7 @@ class RakNativeClient extends EventEmitter {
done(ret.extra.toString())
}
})
- }, 1000)
+ }, timeout, () => { throw new Error('Ping timed out') })
}
connect () {
diff --git a/src/server.js b/src/server.js
index c2dcdf7..b027b73 100644
--- a/src/server.js
+++ b/src/server.js
@@ -2,6 +2,7 @@ const { EventEmitter } = require('events')
const { createDeserializer, createSerializer } = require('./transforms/serializer')
const { Player } = require('./serverPlayer')
const { RakServer } = require('./rak')
+const { sleep } = require('./datatypes/util')
const Options = require('./options')
const debug = globalThis.isElectron ? console.debug : require('debug')('minecraft-protocol')
@@ -31,7 +32,7 @@ class Server extends EventEmitter {
}
onOpenConnection = (conn) => {
- this.inLog('new connection', conn)
+ console.debug('new connection', conn?.address)
const player = new Player(this, conn)
this.clients[conn.address] = player
this.clientCount++
@@ -39,7 +40,7 @@ class Server extends EventEmitter {
}
onCloseConnection = (inetAddr, reason) => {
- console.debug('close connection', inetAddr, reason)
+ console.debug('close connection', inetAddr?.address, reason)
delete this.clients[inetAddr]?.connection // Prevent close loop
this.clients[inetAddr]?.close()
delete this.clients[inetAddr]
@@ -57,7 +58,12 @@ class Server extends EventEmitter {
async listen (hostname = this.options.hostname, port = this.options.port) {
this.raknet = new RakServer({ hostname, port })
- await this.raknet.listen()
+ try {
+ await this.raknet.listen()
+ } catch (e) {
+ console.warn(`Failed to bind server on [${this.options.hostname}]/${this.options.port}, is the port free?`)
+ throw e
+ }
console.debug('Listening on', hostname, port)
this.raknet.onOpenConnection = this.onOpenConnection
this.raknet.onCloseConnection = this.onCloseConnection
@@ -65,14 +71,18 @@ class Server extends EventEmitter {
return { hostname, port }
}
- close (disconnectReason) {
+ async close (disconnectReason) {
for (const caddr in this.clients) {
const client = this.clients[caddr]
client.disconnect(disconnectReason)
}
- this.raknet.close()
+
this.clients = {}
this.clientCount = 0
+
+ // Allow some time for client to get disconnect before closing connection.
+ await sleep(60)
+ this.raknet.close()
}
}
diff --git a/src/server/advertisement.js b/src/server/advertisement.js
index 0b9a401..978008d 100644
--- a/src/server/advertisement.js
+++ b/src/server/advertisement.js
@@ -1,8 +1,10 @@
+const { Versions, CURRENT_VERSION } = require('../options')
+
class ServerName {
motd = 'Bedrock Protocol Server'
name = 'bedrock-protocol'
- protocol = 408
- version = '1.16.20'
+ protocol = Versions[CURRENT_VERSION]
+ version = CURRENT_VERSION
players = {
online: 0,
max: 5
@@ -11,6 +13,14 @@ class ServerName {
gamemode = 'Creative'
serverId = '0'
+ fromString (str) {
+ const [header, motd, protocol, version, playersOnline, playersMax, serverId, name, gamemode] = str.split(';')
+ if (playersOnline) this.players.online = playersOnline
+ if (playersMax) this.players.max = playersMax
+ Object.assign(this, { header, motd, protocol, version, serverId, name, gamemode })
+ return this
+ }
+
toString (version) {
return [
'MCPE',
@@ -35,5 +45,8 @@ module.exports = {
ServerName,
getServerName (client) {
return new ServerName().toBuffer()
+ },
+ fromServerName (string) {
+ return new ServerName().fromString(string)
}
}
diff --git a/src/serverPlayer.js b/src/serverPlayer.js
index d2a6d93..5024a9a 100644
--- a/src/serverPlayer.js
+++ b/src/serverPlayer.js
@@ -38,7 +38,7 @@ class Player extends Connection {
const clientVer = body.protocol_version
if (this.server.options.protocolVersion) {
if (this.server.options.protocolVersion < clientVer) {
- this.sendDisconnectStatus('failed_client')
+ this.sendDisconnectStatus('failed_spawn')
return
}
} else if (clientVer < Options.MIN_VERSION) {
@@ -76,23 +76,22 @@ class Player extends Connection {
* @param {string} playStatus
*/
sendDisconnectStatus (playStatus) {
+ if (this.status === ClientStatus.Disconnected) return
this.write('play_status', { status: playStatus })
- this.close()
+ this.close('kick')
}
/**
* Disconnects a client
*/
disconnect (reason = 'Server closed', hide = false) {
- if ([ClientStatus.Authenticating, ClientStatus.Initializing].includes(this.status)) {
- this.sendDisconnectStatus('failed_server_full')
- } else {
- this.write('disconnect', {
- hide_disconnect_screen: hide,
- message: reason
- })
- }
- this.close()
+ if (this.status === ClientStatus.Disconnected) return
+ this.write('disconnect', {
+ hide_disconnect_screen: hide,
+ message: reason
+ })
+ console.debug('Kicked ', this.connection?.address, reason)
+ setTimeout(() => this.close('kick'), 100) // Allow time for message to be recieved.
}
// After sending Server to Client Handshake, this handles the client's
@@ -105,7 +104,11 @@ class Player extends Connection {
this.emit('join')
}
- close () {
+ close (reason) {
+ if (this.status !== ClientStatus.Disconnected) {
+ this.emit('close') // Emit close once
+ if (!reason) console.trace('Client closed connection', this.connection?.address)
+ }
this.q = []
this.q2 = []
clearInterval(this.loop)
diff --git a/test/internal.js b/test/internal.js
index 09b184b..b811588 100644
--- a/test/internal.js
+++ b/test/internal.js
@@ -2,6 +2,7 @@
const { Server, Client } = require('../')
const { dumpPackets } = require('../tools/genPacketDumps')
const DataProvider = require('../data/provider')
+const { ping } = require('../src/createClient')
// First we need to dump some packets that a vanilla server would send a vanilla
// client. Then we can replay those back in our custom server.
@@ -9,11 +10,11 @@ function prepare (version) {
return dumpPackets(version)
}
-async function startTest (version = '1.16.201', ok) {
+async function startTest (version = '1.16.220', ok) {
await prepare(version)
const Item = require('../types/Item')(version)
const port = 19130
- const server = new Server({ hostname: '0.0.0.0', port, version })
+ const server = new Server({ hostname: '0.0.0.0', port, version, offline: true })
function getPath (packetPath) {
return DataProvider(server.options.protocolVersion).getPath(packetPath)
@@ -26,6 +27,9 @@ async function startTest (version = '1.16.201', ok) {
server.listen()
console.log('Started server')
+ const pongData = await ping({ host: '127.0.0.1', port })
+ console.assert(pongData, 'did not get valid pong data from server')
+
const respawnPacket = get('packets/respawn.json')
const chunks = await requestChunks(respawnPacket.x, respawnPacket.z, 1)
@@ -136,11 +140,14 @@ async function startTest (version = '1.16.201', ok) {
setTimeout(() => {
client.close()
- server.close()
- ok?.()
+ server.close().then(() => {
+ ok?.()
+ })
}, 500)
clearInterval(loop)
})
+
+ client.connect()
}
const { ChunkColumn, Version } = require('bedrock-provider')
diff --git a/test/internal.test.js b/test/internal.test.js
index 3f37b17..c75ad3f 100644
--- a/test/internal.test.js
+++ b/test/internal.test.js
@@ -4,12 +4,12 @@ const { timedTest } = require('./internal')
const { Versions } = require('../src/options')
describe('internal client/server test', function () {
- this.timeout(120 * 1000)
+ this.timeout(220 * 1000)
- it('connects', async () => {
- for (const version in Versions) {
+ for (const version in Versions) {
+ it('connects ' + version, async () => {
console.debug(version)
await timedTest(version)
- }
- })
+ })
+ }
})
diff --git a/test/vanilla.js b/test/vanilla.js
index 3d7c11a..6970a6c 100644
--- a/test/vanilla.js
+++ b/test/vanilla.js
@@ -19,6 +19,7 @@ async function test (version) {
})
console.log('Started client')
+ client.connect()
let loop
diff --git a/test/vanilla.test.js b/test/vanilla.test.js
index 252de3b..2670223 100644
--- a/test/vanilla.test.js
+++ b/test/vanilla.test.js
@@ -4,7 +4,7 @@ const { clientTest } = require('./vanilla')
const { Versions } = require('../src/options')
describe('vanilla server test', function () {
- this.timeout(120 * 1000)
+ this.timeout(220 * 1000)
for (const version in Versions) {
it('client spawns ' + version, async () => {
diff --git a/tools/genPacketDumps.js b/tools/genPacketDumps.js
index d91dc54..94a718b 100644
--- a/tools/genPacketDumps.js
+++ b/tools/genPacketDumps.js
@@ -31,7 +31,7 @@ async function dump (version, force) {
username: 'Boat' + random,
offline: true
})
-
+ client.connect()
return waitFor(async res => {
const root = join(__dirname, `../data/${client.options.version}/sample/`)
if (!fs.existsSync(root + 'packets') || !fs.existsSync(root + 'chunks')) {
diff --git a/tools/startVanillaServer.js b/tools/startVanillaServer.js
index 169f3f7..32d0e0a 100644
--- a/tools/startVanillaServer.js
+++ b/tools/startVanillaServer.js
@@ -108,7 +108,7 @@ async function startServerAndWait (version, withTimeout, options) {
if (!module.parent) {
// if (process.argv.length < 3) throw Error('Missing version argument')
- startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3] } : undefined)
+ startServer(process.argv[2] || '1.16.201', null, process.argv[3] ? { 'server-port': process.argv[3], 'online-mode': !!process.argv[4] } : undefined)
}
module.exports = { fetchLatestStable, startServer, startServerAndWait }
diff --git a/types/Item.js b/types/Item.js
index 1153308..123ceb4 100644
--- a/types/Item.js
+++ b/types/Item.js
@@ -37,6 +37,8 @@ module.exports = (version) =>
network_id: this.networkId,
count: this.count,
metadata: this.metadata,
+ has_stack_id: this.stackId,
+ stack_id: this.stackId,
extra: {
has_nbt: !!this.nbt,
nbt: { version: 1, nbt: this.nbt },