Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [20.x]
node-version: [24.x]
runs-on: ${{ matrix.os }}
timeout-minutes: 8
steps:
Expand Down
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,25 @@ Or with npm to use programmatically:
`npx minecraft-bedrock-server --version 1.18.0 --online --path ./my1.18server`

To query a version's Bedrock protocol metadata, start a temporary server and print
its RakNet PONG response as JSON:
its RakNet or Nethernet advertisement as JSON:

```sh
npx minecraft-bedrock-server -v 1.19.1 --dump-pong-details
```

This includes the raw PONG payload and any fields available from the response,
including `protocolVersion`, `versionName`, MOTDs, player counts, game mode,
and IPv4/IPv6 ports. Incomplete or non-standard PONG responses are returned
with the fields that could be extracted.
The result includes `transport` (`raknet` or `nethernet`) and available metadata:
`protocolVersion`, `versionName`, MOTDs, player counts, and game mode. RakNet
also advertises IPv4/IPv6 ports. `rawPong` contains the RakNet text or the
Nethernet hexadecimal advertisement; Nethernet also reports `advertisementVersion`.
Fields absent from the advertisement are omitted, including game/protocol versions
on older Nethernet v4 advertisements. Discovery and advertisement parsing are provided by `bedrock-protocol`; the advertised game version need not be supported for connections.

Nethernet discovery uses local UDP port 7551 and requires
`enable-lan-visibility=true` (the server default). Run one discoverable local
server at a time when using this helper. The configured `server-port` is the
Nethernet HTTP signalling port, not the discovery port; `server-portv6` is ignored
by Nethernet BDS. Empty advertisements are retried and eventually produce an error.
Older servers continue to use RakNet discovery on their configured ports.

any extraneous -- options will be placed inside the `server.properties` file, e.g. `--level-name coolWorld`.

Expand Down Expand Up @@ -91,7 +100,7 @@ npx minecraft-bedrock-server -v 1.19.1 --dump-pong-details
**via code**

The underlying API `getPongDetails` downloads and starts the requested server version, sends a
RakNet unconnected ping, and stops the server after receiving its response:
discovery request using the transport in its generated `server.properties`, and stops the server after receiving its response:

```js
const details = await bedrockServer.getPongDetails('1.19.1', {
Expand All @@ -105,6 +114,7 @@ The above log or return data in this structure:

```js
{
transport: 'raknet',
rawPong: 'MCPE;Dedicated Server;527;1.19.1;0;10;...',
edition: 'MCPE',
motd: 'Dedicated Server',
Expand Down Expand Up @@ -135,7 +145,7 @@ Options:
--port6 Port to listen on for IPv6 (default: 19133)
--online Whether to run in online mode
--path Custom path to the server directory
--dump-pong-details Start a server and print its RakNet PONG details as JSON
--dump-pong-details Start a server and print its RakNet or Nethernet advertisement as JSON
--versions Passing --versions will list all versions
--download Download (but not run) the server binary for this platfrom (default: linux)
Usage:
Expand All @@ -148,6 +158,8 @@ Usage:

See the exported [TypeScript defs for method docs](src/index.d.ts).

Requires Node.js 24 or newer, matching `bedrock-protocol`.

## Testing
`npm test`

Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@
"dependencies": {
"basic-args": "^1.4.0",
"debug": "^4.3.3",
"prismarine-nbt": "^2.5.0"
"prismarine-nbt": "^2.5.0",
"bedrock-protocol": "^3.60.0"
},
"devDependencies": {
"minecraft-bedrock-server": "file:.",
"mocha": "^10.4.0",
"standard": "^17.1.0"
},
"engines": {
"node": ">=24"
}
}
5 changes: 3 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const opt = require('basic-args')({
path: { type: String, description: 'Custom path to the server directory', default: null },

versions: { type: Boolean, description: 'Passing --versions will list all versions' },
'dump-pong-details': { type: Boolean, description: 'Start a server and print its RakNet PONG details as JSON' },
'dump-pong-details': { type: Boolean, description: 'Start a server and print its RakNet or Nethernet advertisement as JSON' },
download: { type: String, description: `Download (but not run) the server binary for this platfrom (default: ${process.platform})`, default: null }
},
examples: [
Expand Down Expand Up @@ -48,7 +48,8 @@ async function main () {
'server-port': opt.port,
'server-portv6': opt.port6,
'online-mode': Boolean(opt.online),
path: opt.path ? opt.path : undefined
path: opt.path ? opt.path : undefined,
...opt._
})))
} else {
const customOptions = opt._ || {}
Expand Down
34 changes: 20 additions & 14 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ declare module "minecraft-bedrock-server" {
type PermissionLevel = "visitor" | "member" | "operator";

type ServerOptions = {
"transport"?: "raknet" | "nethernet"; // Defaults to the server binary's configured transport
"enable-lan-visibility"?: boolean; // Required for Nethernet discovery
"server-name"?: string; // Used as the server name
"gamemode"?: GameMode; // Sets the game mode for new players
"force-gamemode"?: boolean; // Prevents the server from sending to the client gamemode values other than the gamemode value saved by the server during world creation
Expand Down Expand Up @@ -69,22 +71,26 @@ declare module "minecraft-bedrock-server" {
function startServerAndWait2(version: string, withTimeout: number, options: ServerOptionsEx): Promise<ChildProcess>

type PongDetails = {
rawPong: string,
edition: string,
motd: string,
protocolVersion: number,
versionName: string,
playerCount: number,
maxPlayerCount: number,
serverUniqueId: string,
motd2: string,
gameMode: string,
gameModeNumeric: number,
portIPv4: number,
portIPv6: number
transport: "raknet" | "nethernet",
rawPong: string, // RakNet text or Nethernet hexadecimal advertisement
advertisementVersion?: number,
edition?: string,
motd?: string,
protocolVersion?: number,
versionName?: string,
playerCount?: number,
maxPlayerCount?: number,
serverUniqueId?: string,
motd2?: string,
gameMode?: string,
gameModeNumeric?: number,
portIPv4?: number,
portIPv6?: number
}

// Starts a server, queries its RakNet PONG details, and stops it.
function parsePongDetails(buffer: Buffer): PongDetails

// Starts a server, queries its RakNet or Nethernet advertisement, and stops it.
function getPongDetails(version: string, options?: ServerOptionsEx & { timeout?: number, pingTimeout?: number, pongRetries?: number }): Promise<PongDetails>

interface BedrockVanillaServer {
Expand Down
135 changes: 45 additions & 90 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
const http = require('https')
const fs = require('fs')
const cp = require('child_process')
const dgram = require('dgram')
const crypto = require('crypto')
const { ping, ServerAdvertisement } = require('bedrock-protocol')
const { join, resolve } = require('path')
const debug = process.env.CI ? console.debug : require('debug')('minecraft-bedrock-server')
const https = require('https')
Expand Down Expand Up @@ -237,95 +236,47 @@ async function startServerAndWait2 (version, withTimeout, options) {
}
}

const raknetMagic = Buffer.from('00ffff00fefefefefdfdfdfd12345678', 'hex')

function parsePongDetails (buffer) {
const stringLength = buffer.length >= 35 ? buffer.readUInt16BE(33) : 0
const rawPong = buffer.subarray(35, Math.min(buffer.length, 35 + stringLength)).toString()
const [
edition,
motd,
protocolVersion,
versionName,
playerCount,
maxPlayerCount,
serverUniqueId,
motd2,
gameMode,
gameModeNumeric,
portIPv4,
portIPv6
] = rawPong.split(';')
const number = value => value && Number.isFinite(Number(value)) ? Number(value) : undefined
function pongDetails (ad, transport) {
const nethernet = transport === 'nethernet'
return {
rawPong,
edition,
motd,
protocolVersion: number(protocolVersion),
versionName,
playerCount: number(playerCount),
maxPlayerCount: number(maxPlayerCount),
serverUniqueId,
motd2,
gameMode,
gameModeNumeric: number(gameModeNumeric),
portIPv4: number(portIPv4),
portIPv6: number(portIPv6)
transport,
rawPong: ad.raw,
advertisementVersion: nethernet ? ad.version : undefined,
edition: ad.header,
motd: ad.motd,
protocolVersion: nethernet && ad.version !== 7 ? undefined : Number(ad.protocol) || undefined,
versionName: nethernet ? (ad.version === 7 ? ad.gameVersion : undefined) : ad.version,
playerCount: nethernet ? ad.playerCount : ad.playersOnline,
maxPlayerCount: ad.playersMax,
serverUniqueId: nethernet ? ad.networkId?.toString() : ad.serverId,
motd2: ad.levelName,
gameMode: ad.gamemode,
gameModeNumeric: ad.gamemodeId,
portIPv4: ad.portV4,
portIPv6: ad.portV6
}
}

function requestPong (port, timeout = 5000) {
return new Promise((resolve, reject) => {
const targets = Array.isArray(port) ? port : [{ port, host: '127.0.0.1', type: 'udp4' }]
const sockets = new Map()
const ping = Buffer.alloc(33)
ping[0] = 0x01
ping.writeBigInt64BE(BigInt(Date.now()), 1)
raknetMagic.copy(ping, 9)
crypto.randomBytes(8).copy(ping, 25)
let bestPong
let closed = false
const close = () => {
if (closed) return
closed = true
clearTimeout(timer)
clearInterval(interval)
for (const socket of sockets.values()) socket.close()
}
const sendPing = () => {
for (const target of targets) sockets.get(target.type)?.send(ping, target.port, target.host)
}
const interval = setInterval(sendPing, 250)
const timer = setTimeout(() => {
close()
if (bestPong) resolve(bestPong)
else reject(new Error('Timed out waiting for RakNet PONG'))
}, timeout)
for (const target of targets) {
if (!sockets.has(target.type)) {
const socket = dgram.createSocket(target.type)
socket.on('message', (message) => {
const pong = parsePongDetails(message)
if (pong.rawPong) {
close()
resolve(pong)
} else {
bestPong = pong
}
})
socket.on('error', () => {
socket.close()
sockets.delete(target.type)
if (!sockets.size) {
close()
reject(new Error('Unable to send RakNet PONG request'))
}
})
sockets.set(target.type, socket)
}
}
sendPing()
})
// Retain the public RakNet packet helper, delegating the advertisement parsing.
function parsePongDetails (buffer) {
const length = buffer.length >= 35 ? buffer.readUInt16BE(33) : 0
const raw = buffer.subarray(35, 35 + length).toString()
return pongDetails(new ServerAdvertisement().fromString(raw), 'raknet')
}

async function requestPong (targets, timeout, transport) {
const controller = new AbortController()
try {
return await Promise.any(targets.map(async ({ host, port }) => {
const ad = await ping({ host, port, transport, timeout, signal: controller.signal })
if (!ad.raw) throw new Error(`Empty ${transport} advertisement`)
return pongDetails(ad, transport)
}))
} catch (error) {
throw error.errors[0]
} finally {
controller.abort()
}
}

async function getPongDetails (version, options = { 'server-port': 19130, 'server-portv6': 19133 }) {
Expand All @@ -335,13 +286,17 @@ async function getPongDetails (version, options = { 'server-port': 19130, 'serve
if (!Number.isInteger(pongRetries) || pongRetries < 1) throw new Error('pongRetries must be a positive integer')
const handle = await startServerAndWait(version, timeout, serverOptions)
try {
const ports = [{ port, host: '127.0.0.1', type: 'udp4' }]
const directory = resolve(serverOptions.root || '.', serverOptions.path || 'bds-' + version)
const properties = fs.readFileSync(join(directory, 'server.properties'), 'utf8')
const transport = [...properties.matchAll(/^transport\s*=\s*(\w+)/gm)].at(-1)?.[1] || 'raknet'
if (!['raknet', 'nethernet'].includes(transport)) throw new Error(`Unsupported server transport: ${transport}`)
const ports = [{ port: transport === 'nethernet' ? 7551 : port, host: '127.0.0.1', type: 'udp4' }]
const port6 = Number(options['server-portv6'])
if (port6) ports.push({ port: port6, host: '::1', type: 'udp6' })
if (port6 && transport === 'raknet') ports.push({ port: port6, host: '::1', type: 'udp6' })
let lastError
for (let attempt = 0; attempt < pongRetries; attempt++) {
try {
return await requestPong(ports, pingTimeout)
return await requestPong(ports, pingTimeout, transport)
} catch (error) {
lastError = error
}
Expand Down
41 changes: 25 additions & 16 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const bedrockServer = require('minecraft-bedrock-server')
const fs = require('fs')
const assert = require('assert')
const { join } = require('path')
const versions = ['1.16.210', '1.18.0', '1.21.80']
const versions = ['1.16.210', '1.18.0', '1.21.80', '1.26.51']

for (const version of versions) {
describe(`${version}`, function () {
Expand Down Expand Up @@ -42,6 +42,8 @@ describe('auxiliary methods', function () {
packet.writeUInt16BE(Buffer.byteLength(details), 33)
packet.write(details, 35)
assert.deepStrictEqual(bedrockServer.parsePongDetails(packet), {
transport: 'raknet',
advertisementVersion: undefined,
rawPong: details,
edition: 'MCPE',
motd: 'Dedicated Server',
Expand Down Expand Up @@ -80,22 +82,28 @@ describe('auxiliary methods', function () {
)
})

it('extracts PONG details from a real server', async function () {
this.timeout(90000)
const path = join(__dirname, 'bds-1.21.80')
const executable = process.platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server'
if (!fs.existsSync(join(path, executable))) this.skip()
const port = 19132 + ((Math.random() * 1000) | 0)
const details = await bedrockServer.getPongDetails('1.21.80', {
path,
'server-port': port,
'server-portv6': port + 1
for (const [version, transport] of [['1.21.80', 'raknet'], ['1.26.51', 'nethernet']]) {
it(`extracts ${transport} metadata from a real ${version} server`, async function () {
this.timeout(90000)
const path = join(__dirname, 'bds-' + version)
const executable = process.platform === 'win32' ? 'bedrock_server.exe' : 'bedrock_server'
if (!fs.existsSync(join(path, executable))) this.skip()
const port = 19132 + ((Math.random() * 1000) | 0)
const details = await bedrockServer.getPongDetails(version, {
path,
'server-port': port,
'server-portv6': port + 1
})
assert.strictEqual(details.transport, transport)
assert(details.rawPong)
assert(details.protocolVersion > 0)
assert.strictEqual(details.versionName.split('.').slice(0, 3).join('.'), version)
if (transport === 'raknet') {
assert.strictEqual(details.portIPv4, port)
assert.strictEqual(details.portIPv6, port + 1)
}
})
assert(details.rawPong)
assert(details.protocolVersion > 0)
assert.strictEqual(details.portIPv4, port)
assert.strictEqual(details.portIPv6, port + 1)
})
}

it('getLatestVersions works', async function () {
const versions = await bedrockServer.getLatestVersions()
Expand All @@ -113,6 +121,7 @@ describe('auxiliary methods', function () {

describe('cli', function () {
it('works', function () {
this.timeout(10000)
const helpStr = cp.execSync('npx minecraft-bedrock-server --help').toString()
assert(helpStr)
})
Expand Down
Loading