feat(minecraft-hub): init, but bugged

This commit is contained in:
Neko Ayaka
2026-02-18 11:13:33 +08:00
parent 9ee5aa86ad
commit 59b274b91a
9 changed files with 726 additions and 51 deletions
+70 -51
View File
@@ -3153,25 +3153,25 @@ importers:
version: 3.102.3
mineflayer:
specifier: ^4.33.0
version: 4.33.0(encoding@0.1.13)
version: 4.33.0
mineflayer-armor-manager:
specifier: ^2.0.1
version: 2.0.1(mineflayer@4.33.0(encoding@0.1.13))
version: 2.0.1(mineflayer@4.33.0)
mineflayer-auto-eat:
specifier: ^5.0.3
version: 5.0.3(encoding@0.1.13)
version: 5.0.3
mineflayer-collectblock:
specifier: ^1.6.0
version: 1.6.0(encoding@0.1.13)
version: 1.6.0(prismarine-registry@1.11.0)
mineflayer-pathfinder:
specifier: ^2.4.5
version: 2.4.5
version: 2.4.5(prismarine-registry@1.11.0)
mineflayer-pvp:
specifier: ^1.3.2
version: 1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0)
version: 1.3.2(prismarine-registry@1.11.0)
mineflayer-tool:
specifier: ^1.2.0
version: 1.2.0(encoding@0.1.13)
version: 1.2.0(prismarine-registry@1.11.0)
nanoid:
specifier: 'catalog:'
version: 5.1.6
@@ -3180,7 +3180,7 @@ importers:
version: 0.2.1(zod-to-json-schema@3.25.1(zod@4.3.5))(zod@4.3.5)
prismarine-block:
specifier: ^1.22.0
version: 1.22.0
version: 1.22.0(prismarine-registry@1.11.0)
prismarine-entity:
specifier: ^2.5.0
version: 2.5.0
@@ -3192,7 +3192,7 @@ importers:
version: 1.3.1(prismarine-registry@1.11.0)
prismarine-viewer:
specifier: ^1.33.0
version: 1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
version: 1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10)
prismarine-windows:
specifier: ^2.9.0
version: 2.9.0
@@ -3219,6 +3219,18 @@ importers:
specifier: 'catalog:'
version: 8.18.1
services/minecraft-hub:
dependencies:
'@guiiai/logg':
specifier: 'catalog:'
version: 1.2.11
minecraft-protocol:
specifier: ^1.62.0
version: 1.62.0(encoding@0.1.13)
tsx:
specifier: ^4.21.0
version: 4.21.0
services/satori-bot:
dependencies:
'@guiiai/logg':
@@ -27903,61 +27915,65 @@ snapshots:
- encoding
- supports-color
mineflayer-armor-manager@2.0.1(mineflayer@4.33.0(encoding@0.1.13)):
mineflayer-armor-manager@2.0.1(mineflayer@4.33.0):
dependencies:
minecraft-data: 3.102.3
mineflayer: 4.33.0(encoding@0.1.13)
mineflayer: 4.33.0
mineflayer-auto-eat@5.0.3(encoding@0.1.13):
mineflayer-auto-eat@5.0.3:
dependencies:
'@nxg-org/mineflayer-util-plugin': 1.8.4
mineflayer: 4.33.0(encoding@0.1.13)
mineflayer: 4.33.0
transitivePeerDependencies:
- encoding
- supports-color
mineflayer-collectblock@1.6.0(encoding@0.1.13):
mineflayer-collectblock@1.6.0(prismarine-registry@1.11.0):
dependencies:
mineflayer: 4.33.0(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5
mineflayer-tool: 1.2.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
- supports-color
mineflayer-pathfinder@2.4.5:
dependencies:
minecraft-data: 3.102.3
prismarine-block: 1.22.0
prismarine-entity: 2.5.0
prismarine-item: 1.17.0
prismarine-nbt: 2.8.0
prismarine-physics: 1.10.0
vec3: 0.1.10
mineflayer-pvp@1.3.2(encoding@0.1.13)(prismarine-registry@1.11.0):
dependencies:
mineflayer: 4.33.0(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5
mineflayer-utils: 0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0)
mineflayer: 4.33.0
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
mineflayer-tool: 1.2.0(prismarine-registry@1.11.0)
transitivePeerDependencies:
- encoding
- prismarine-registry
- supports-color
mineflayer-tool@1.2.0(encoding@0.1.13):
mineflayer-pathfinder@2.4.5(prismarine-registry@1.11.0):
dependencies:
mineflayer: 4.33.0(encoding@0.1.13)
mineflayer-pathfinder: 2.4.5
minecraft-data: 3.102.3
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-entity: 2.5.0
prismarine-item: 1.17.0
prismarine-nbt: 2.8.0
prismarine-physics: 1.10.0
vec3: 0.1.10
transitivePeerDependencies:
- prismarine-registry
mineflayer-pvp@1.3.2(prismarine-registry@1.11.0):
dependencies:
mineflayer: 4.33.0
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
mineflayer-utils: 0.1.4(prismarine-registry@1.11.0)
transitivePeerDependencies:
- encoding
- prismarine-registry
- supports-color
mineflayer-tool@1.2.0(prismarine-registry@1.11.0):
dependencies:
mineflayer: 4.33.0
mineflayer-pathfinder: 2.4.5(prismarine-registry@1.11.0)
prismarine-nbt: 2.8.0
transitivePeerDependencies:
- encoding
- prismarine-registry
- supports-color
mineflayer-utils@0.1.4(encoding@0.1.13)(prismarine-registry@1.11.0):
mineflayer-utils@0.1.4(prismarine-registry@1.11.0):
dependencies:
'@types/node': 14.18.63
mineflayer: 2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0)
mineflayer: 2.41.0(prismarine-registry@1.11.0)
prismarine-entity: 1.2.0
require-self: 0.2.3
typescript: 3.9.10
@@ -27966,12 +27982,12 @@ snapshots:
- prismarine-registry
- supports-color
mineflayer@2.41.0(encoding@0.1.13)(prismarine-registry@1.11.0):
mineflayer@2.41.0(prismarine-registry@1.11.0):
dependencies:
minecraft-data: 2.221.0
minecraft-protocol: 1.62.0(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0)
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-chat: 1.12.0
prismarine-chunk: 1.39.0(minecraft-data@2.221.0)
prismarine-entity: 1.2.0
@@ -27988,12 +28004,12 @@ snapshots:
- prismarine-registry
- supports-color
mineflayer@4.33.0(encoding@0.1.13):
mineflayer@4.33.0:
dependencies:
minecraft-data: 3.102.3
minecraft-protocol: 1.62.0(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-chat: 1.12.0
prismarine-chunk: 1.39.0(minecraft-data@3.102.3)
prismarine-entity: 2.5.0
@@ -29055,7 +29071,7 @@ snapshots:
minecraft-data: 3.102.3
prismarine-registry: 1.11.0
prismarine-block@1.22.0:
prismarine-block@1.22.0(prismarine-registry@1.11.0):
dependencies:
minecraft-data: 3.102.3
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
@@ -29063,6 +29079,8 @@ snapshots:
prismarine-item: 1.17.0
prismarine-nbt: 2.8.0
prismarine-registry: 1.11.0
transitivePeerDependencies:
- prismarine-registry
prismarine-chat@1.12.0:
dependencies:
@@ -29073,7 +29091,7 @@ snapshots:
prismarine-chunk@1.39.0(minecraft-data@2.221.0):
dependencies:
prismarine-biome: 1.3.0(minecraft-data@2.221.0)(prismarine-registry@1.11.0)
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-nbt: 2.8.0
prismarine-registry: 1.11.0
smart-buffer: 4.2.0
@@ -29086,7 +29104,7 @@ snapshots:
prismarine-chunk@1.39.0(minecraft-data@3.102.3):
dependencies:
prismarine-biome: 1.3.0(minecraft-data@3.102.3)(prismarine-registry@1.11.0)
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-nbt: 2.8.0
prismarine-registry: 1.11.0
smart-buffer: 4.2.0
@@ -29137,16 +29155,16 @@ snapshots:
prismarine-registry@1.11.0:
dependencies:
minecraft-data: 3.102.3
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-nbt: 2.8.0
prismarine-viewer@1.33.0(bufferutil@4.1.0)(utf-8-validate@5.0.10):
prismarine-viewer@1.33.0(bufferutil@4.1.0)(prismarine-registry@1.11.0)(utf-8-validate@5.0.10):
dependencies:
'@tweenjs/tween.js': 23.1.3
compression: 1.8.1
express: 4.22.1
minecraft-data: 3.102.3
prismarine-block: 1.22.0
prismarine-block: 1.22.0(prismarine-registry@1.11.0)
prismarine-chunk: 1.39.0(minecraft-data@3.102.3)
prismarine-world: 3.6.3
socket.io: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)
@@ -29156,6 +29174,7 @@ snapshots:
vec3: 0.1.10
transitivePeerDependencies:
- bufferutil
- prismarine-registry
- supports-color
- utf-8-validate
+33
View File
@@ -0,0 +1,33 @@
# Hub owns the single upstream session
HUB_SINGLE_TARGET=true
HUB_VERSION=1.21.8
HUB_MOTD=AIRI Minecraft Hub
# Viewer listener (online-mode recommended for skins)
HUB_VIEWER_LISTEN_HOST=0.0.0.0
HUB_VIEWER_LISTEN_PORT=25566
HUB_VIEWER_ONLINE_MODE=true
HUB_VIEWER_USERNAME=airi-bot
# Bot listener (offline)
HUB_BOT_LISTEN_HOST=0.0.0.0
HUB_BOT_LISTEN_PORT=25567
HUB_BOT_ONLINE_MODE=false
HUB_BOT_USERNAME=airi-bot-mineflayer
# Target server (real Minecraft server)
HUB_UPSTREAM_HOST=localhost
HUB_UPSTREAM_PORT=25565
HUB_UPSTREAM_AUTH=microsoft
HUB_UPSTREAM_USERNAME=airi-bot
HUB_REWRITE_IDENTITY=false
HUB_DEBUG_PACKETS=false
HUB_DUMP_PACKETS=true
HUB_DUMP_DIR=./packet-dumps
# Mirroring and input filters
HUB_MIRROR_MOVEMENT=true
HUB_MIRROR_ACTIONS=false
HUB_BLOCK_VIEWER_MOVEMENT=true
HUB_BLOCK_VIEWER_ACTIONS=true
+47
View File
@@ -0,0 +1,47 @@
# Minecraft Hub (Protocol Relay)
Relay service that accepts Minecraft clients (bot + viewer) and proxies them to a target Minecraft server. The hub owns a single upstream authenticated session, and both clients are downstream-only.
## Usage
```bash
pnpm -F @proj-airi/minecraft-hub dev
```
## Environment Variables
Core:
- `HUB_VERSION` (default: `1.20`)
- `HUB_MOTD` (default: `AIRI Minecraft Hub`)
Viewer listener (debug client):
- `HUB_VIEWER_LISTEN_HOST` (default: `0.0.0.0`)
- `HUB_VIEWER_LISTEN_PORT` (default: `25566`)
- `HUB_VIEWER_ONLINE_MODE` (default: `true`)
- `HUB_VIEWER_USERNAME` (optional, defaults to `HUB_UPSTREAM_USERNAME`)
Bot listener (mineflayer):
- `HUB_BOT_LISTEN_HOST` (default: `0.0.0.0`)
- `HUB_BOT_LISTEN_PORT` (default: `25567`)
- `HUB_BOT_ONLINE_MODE` (default: `false`)
- `HUB_BOT_USERNAME` (default: `airi-bot-mineflayer`)
Target server (real Minecraft server):
- `HUB_UPSTREAM_HOST` (default: `localhost`)
- `HUB_UPSTREAM_PORT` (default: `25565`)
- `HUB_UPSTREAM_AUTH` (default: `offline`) — `offline` | `mojang` | `microsoft`
- `HUB_UPSTREAM_USERNAME` (default: `airi-bot`)
- `HUB_REWRITE_IDENTITY` (default: `false`) — when enabled, rewrites player identity packets for downstream clients
- `HUB_DEBUG_PACKETS` (default: `false`) — log raw/decoded packet flow for debugging
- `HUB_DUMP_PACKETS` (default: `false`) — write raw packet hex dumps to disk
- `HUB_DUMP_DIR` (default: `./packet-dumps`) — dump directory
Mirroring / input filters:
- `HUB_MIRROR_MOVEMENT` (default: `true`)
- `HUB_MIRROR_ACTIONS` (default: `false`)
## Notes
- The hub connects to the target server once and broadcasts server packets to both downstream clients.
- The viewer is input-blocked and acts as a debug view while the bot controls the upstream session.
- The hub rewrites UUIDs in key packets (player info/spawn) so downstream clients can keep their own local identities while the hub owns the upstream account.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@proj-airi/minecraft-hub",
"type": "module",
"private": true,
"description": "Minecraft protocol relay hub for AIRI",
"author": {
"name": "Moeru AI Project AIRI Team",
"email": "airi@moeru.ai",
"url": "https://github.com/moeru-ai"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moeru-ai/airi.git",
"directory": "services/minecraft-hub"
},
"scripts": {
"start": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts",
"dev": "tsx --env-file=.env --env-file-if-exists=.env.local src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@guiiai/logg": "catalog:",
"minecraft-protocol": "^1.62.0",
"tsx": "^4.21.0"
}
}
+76
View File
@@ -0,0 +1,76 @@
import { env } from 'node:process'
export type AuthType = 'offline' | 'mojang' | 'microsoft'
export interface HubConfig {
version: string
motd: string
viewerListenHost: string
viewerListenPort: number
viewerOnlineMode: boolean
viewerUsername?: string
botListenHost: string
botListenPort: number
botOnlineMode: boolean
botUsername: string
upstreamHost: string
upstreamPort: number
upstreamAuth: AuthType
upstreamUsername: string
rewriteIdentity: boolean
debugPackets: boolean
dumpPackets: boolean
dumpDir: string
mirrorMovement: boolean
mirrorActions: boolean
}
function getEnvVar<V extends string>(key: string, fallback: V): V {
return (env[key] || fallback) as V
}
function getEnvNumber(key: string, fallback: number): number {
return Number.parseInt(env[key] || String(fallback))
}
function getEnvBool(key: string, fallback: boolean): boolean {
const value = env[key]
if (value === undefined)
return fallback
return value.toLowerCase() === 'true' || value === '1'
}
export function loadConfig(): HubConfig {
return {
version: getEnvVar('HUB_VERSION', '1.20'),
motd: getEnvVar('HUB_MOTD', 'AIRI Minecraft Hub'),
viewerListenHost: getEnvVar('HUB_VIEWER_LISTEN_HOST', '0.0.0.0'),
viewerListenPort: getEnvNumber('HUB_VIEWER_LISTEN_PORT', 25566),
viewerOnlineMode: getEnvBool('HUB_VIEWER_ONLINE_MODE', true),
viewerUsername: env.HUB_VIEWER_USERNAME,
botListenHost: getEnvVar('HUB_BOT_LISTEN_HOST', '0.0.0.0'),
botListenPort: getEnvNumber('HUB_BOT_LISTEN_PORT', 25567),
botOnlineMode: getEnvBool('HUB_BOT_ONLINE_MODE', false),
botUsername: getEnvVar('HUB_BOT_USERNAME', 'airi-bot-mineflayer'),
upstreamHost: getEnvVar('HUB_UPSTREAM_HOST', 'localhost'),
upstreamPort: getEnvNumber('HUB_UPSTREAM_PORT', 25565),
upstreamAuth: getEnvVar('HUB_UPSTREAM_AUTH', 'offline') as AuthType,
upstreamUsername: getEnvVar('HUB_UPSTREAM_USERNAME', 'airi-bot'),
rewriteIdentity: getEnvBool('HUB_REWRITE_IDENTITY', false),
debugPackets: getEnvBool('HUB_DEBUG_PACKETS', false),
dumpPackets: getEnvBool('HUB_DUMP_PACKETS', false),
dumpDir: getEnvVar('HUB_DUMP_DIR', './packet-dumps'),
mirrorMovement: getEnvBool('HUB_MIRROR_MOVEMENT', true),
mirrorActions: getEnvBool('HUB_MIRROR_ACTIONS', false),
}
}
+414
View File
@@ -0,0 +1,414 @@
import type { Buffer } from 'node:buffer'
import type { HubConfig } from './config'
import { createWriteStream, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { createClient, createServer } from 'minecraft-protocol'
import { useLogger } from './logger'
type ProtocolClient = any
type HubRole = 'bot' | 'viewer'
interface HubSession {
role: HubRole
username: string
downstream: ProtocolClient
readyForPlay: boolean
}
const MOVEMENT_PACKETS = new Set([
'position',
'position_look',
'look',
'move',
'vehicle_move',
])
const ACTION_PACKETS = new Set([
'arm_animation',
'block_dig',
'block_place',
'use_item',
'use_entity',
'held_item_slot',
'player_command',
'entity_action',
])
export interface HubHandle {
close: () => Promise<void>
}
function rewriteUuidArray(uuids: string[], targetUuid: string, replacementUuid: string) {
return uuids.map(uuid => (uuid === targetUuid ? replacementUuid : uuid))
}
function rewritePacketForSession(name: string, data: any, session: HubSession, targetUsername?: string, targetUuid?: string) {
if (!targetUuid || !session.downstream?.uuid)
return data
const replacementUuid = session.downstream.uuid
const replacementName = session.username
if (targetUuid === replacementUuid && (!targetUsername || targetUsername === replacementName))
return data
if (name === 'player_info' && Array.isArray(data.data)) {
return {
...data,
data: data.data.map((entry: any) => {
if (entry.uuid !== targetUuid)
return entry
return {
...entry,
uuid: replacementUuid,
name: targetUsername && entry.name === targetUsername ? replacementName : entry.name,
}
}),
}
}
if (name === 'player_info_update' && Array.isArray(data.data)) {
return {
...data,
data: data.data.map((entry: any) => {
if (entry.uuid !== targetUuid)
return entry
return {
...entry,
uuid: replacementUuid,
name: targetUsername && entry.name === targetUsername ? replacementName : entry.name,
}
}),
}
}
if (name === 'player_remove') {
if (Array.isArray(data.players))
return { ...data, players: rewriteUuidArray(data.players, targetUuid, replacementUuid) }
if (Array.isArray(data.uuids))
return { ...data, uuids: rewriteUuidArray(data.uuids, targetUuid, replacementUuid) }
}
if ((name === 'named_entity_spawn' || name === 'spawn_player') && data.playerUUID) {
if (data.playerUUID !== targetUuid)
return data
return { ...data, playerUUID: replacementUuid }
}
if (typeof data === 'object' && data !== null && data.uuid === targetUuid)
return { ...data, uuid: replacementUuid }
return data
}
export async function startHub(config: HubConfig): Promise<HubHandle> {
const logger = useLogger('minecraft-hub')
const sessions = new Set<HubSession>()
const configPacketBuffer: Array<{ buffer: Buffer, name: string, compressionThreshold?: number }> = []
const playPacketBuffer: Array<{ name: string, data: any }> = []
let configCompleted = false
let configCompressionThreshold: number | null = null
let initialDispatchDone = false
let rawDumpStream: NodeJS.WritableStream | null = null
if (config.dumpPackets) {
mkdirSync(config.dumpDir, { recursive: true })
const filename = `hub-${new Date().toISOString().replace(/[:.]/g, '-')}.log`
const path = join(config.dumpDir, filename)
rawDumpStream = createWriteStream(path, { flags: 'a' })
logger.withFields({ path }).log('Packet dump enabled')
}
const dumpRaw = (direction: string, meta: { name: string, state: string }, buffer: Buffer) => {
if (!rawDumpStream)
return
const line = JSON.stringify({
ts: new Date().toISOString(),
direction,
state: meta.state,
name: meta.name,
size: buffer.length,
hex: buffer.toString('hex'),
})
rawDumpStream.write(`${line}\n`)
}
const mirrorToViewers = (name: string, data: unknown) => {
for (const session of sessions) {
if (session.role !== 'viewer')
continue
if (!session.readyForPlay)
continue
try {
session.downstream.write(name, data as never)
}
catch (error) {
logger.withFields({ packet: name, viewer: session.username }).errorWithError('Failed to mirror packet to viewer', error as Error)
}
}
}
const hasRole = (role: HubRole) => {
for (const session of sessions) {
if (session.role === role)
return true
}
return false
}
const canDispatchInitialPackets = () => configCompleted && hasRole('viewer') && hasRole('bot') && !initialDispatchDone
const dispatchInitialPackets = () => {
if (!canDispatchInitialPackets())
return
for (const session of sessions) {
session.downstream.state = 'configuration'
for (const packet of configPacketBuffer) {
session.downstream.writeRaw(packet.buffer)
if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number')
session.downstream.compressionThreshold = packet.compressionThreshold
if (config.dumpPackets)
dumpRaw(`hub->downstream:${session.role}`, { name: packet.name, state: 'configuration' }, packet.buffer)
}
session.downstream.state = 'play'
session.readyForPlay = true
}
for (const packet of playPacketBuffer) {
for (const session of sessions) {
if (!session.readyForPlay)
continue
try {
const payload = config.rewriteIdentity
? rewritePacketForSession(packet.name, packet.data, session, targetIdentity.username, targetIdentity.uuid)
: packet.data
session.downstream.write(packet.name, payload)
}
catch (error) {
logger.withFields({ packet: packet.name, role: session.role }).errorWithError('Failed to replay packet to downstream', error as Error)
}
}
}
playPacketBuffer.length = 0
initialDispatchDone = true
}
const target = createClient({
host: config.upstreamHost,
port: config.upstreamPort,
username: config.upstreamUsername,
auth: config.upstreamAuth === 'offline' ? undefined : config.upstreamAuth,
version: config.version,
})
const targetIdentity = {
username: config.upstreamUsername,
uuid: '',
}
target.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => {
if (config.debugPackets)
logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name, size: buffer.length }).log('Raw packet')
if (config.dumpPackets)
dumpRaw('target->hub', meta, buffer)
if (meta.state !== 'configuration' && (meta.state !== 'play' || config.rewriteIdentity))
return
if (meta.state === 'configuration') {
configPacketBuffer.push({ buffer, name: meta.name, compressionThreshold: configCompressionThreshold ?? undefined })
}
})
target.on('packet', (data: any, meta: { name: string, state: string }) => {
if (config.debugPackets)
logger.withFields({ direction: 'target->hub', state: meta.state, name: meta.name }).log('Decoded packet')
if (meta.state === 'configuration' && meta.name === 'set_compression' && typeof data.threshold === 'number')
configCompressionThreshold = data.threshold
if (meta.name === 'player_info' && Array.isArray(data.data)) {
const hubEntry = data.data.find((entry: any) => entry.name === config.upstreamUsername)
if (hubEntry?.uuid)
targetIdentity.uuid = hubEntry.uuid
}
if (!configCompleted && meta.state === 'configuration' && meta.name === 'finish_configuration') {
configCompleted = true
dispatchInitialPackets()
}
if (meta.state === 'play') {
if (!initialDispatchDone) {
playPacketBuffer.push({ name: meta.name, data })
return
}
for (const session of sessions) {
if (!session.readyForPlay)
continue
try {
if (config.debugPackets)
logger.withFields({ direction: 'hub->downstream', state: meta.state, name: meta.name, role: session.role }).log('Decoded forward')
const payload = config.rewriteIdentity
? rewritePacketForSession(meta.name, data, session, targetIdentity.username, targetIdentity.uuid)
: data
session.downstream.write(meta.name, payload)
}
catch (error) {
logger.withFields({ packet: meta.name, role: session.role }).errorWithError('Failed to forward packet to downstream', error as Error)
}
}
}
})
target.on('end', () => {
for (const session of sessions) {
if (!session.downstream.ended)
session.downstream.end('target_end')
}
})
target.on('error', error => logger.errorWithError('Target server error', error))
const createListener = (role: HubRole, host: string, port: number, onlineMode: boolean) => {
const server = createServer({
host,
port,
'version': config.version,
'motd': config.motd,
'maxPlayers': 10,
'online-mode': onlineMode,
})
server.on('login', (downstream: ProtocolClient) => {
const isViewer = role === 'viewer'
const expectedViewer = config.viewerUsername || config.upstreamUsername
if (isViewer && expectedViewer && downstream.username !== expectedViewer) {
logger.withFields({ username: downstream.username }).error('Rejected viewer: username mismatch')
downstream.end('viewer_username_mismatch')
return
}
if (!isViewer && downstream.username !== config.botUsername) {
logger.withFields({ username: downstream.username }).error('Rejected bot: username mismatch')
downstream.end('bot_username_mismatch')
return
}
const session: HubSession = { role, username: downstream.username, downstream, readyForPlay: false }
sessions.add(session)
logger.withFields({ role, username: downstream.username }).log('Hub client connected')
downstream.state = 'configuration'
if (configCompleted && initialDispatchDone) {
for (const packet of configPacketBuffer) {
downstream.writeRaw(packet.buffer)
if (packet.name === 'set_compression' && typeof packet.compressionThreshold === 'number')
downstream.compressionThreshold = packet.compressionThreshold
}
downstream.state = 'play'
session.readyForPlay = true
}
dispatchInitialPackets()
const relayDownstreamToTarget = (data: any, meta: { name: string, state: string }) => {
if (meta.state !== 'play')
return
if (role === 'viewer')
return
try {
if (role === 'bot' && (target as any).state === 'play')
target.write(meta.name, data)
if (config.debugPackets)
logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role }).log('Decoded upstream send')
}
catch (error) {
logger.withFields({ packet: meta.name, role }).errorWithError('Failed to forward packet to target server', error as Error)
}
if (role === 'bot' && config.mirrorMovement && MOVEMENT_PACKETS.has(meta.name))
mirrorToViewers(meta.name, data)
if (role === 'bot' && config.mirrorActions && ACTION_PACKETS.has(meta.name))
mirrorToViewers(meta.name, data)
}
if (!config.rewriteIdentity && role === 'bot') {
downstream.on('raw', (buffer: Buffer, meta: { name: string, state: string }) => {
if (meta.state !== 'play')
return
if ((target as any).state === 'play')
target.writeRaw(buffer)
if (config.debugPackets)
logger.withFields({ direction: 'downstream->hub', state: meta.state, name: meta.name, role, size: buffer.length }).log('Raw upstream send')
if (config.dumpPackets)
dumpRaw('downstream->hub', meta, buffer)
})
}
else {
downstream.on('packet', relayDownstreamToTarget)
}
const cleanup = (reason: string) => {
sessions.delete(session)
logger.withFields({ role, username: downstream.username, reason }).log('Closing hub session')
if (!downstream.ended)
downstream.end(reason)
}
downstream.on('end', () => cleanup('downstream_end'))
downstream.on('error', error => logger.errorWithError('Downstream error', error))
})
server.on('error', error => logger.errorWithError('Hub server error', error as Error))
return server
}
const viewerServer = createListener('viewer', config.viewerListenHost, config.viewerListenPort, config.viewerOnlineMode)
const botServer = createListener('bot', config.botListenHost, config.botListenPort, config.botOnlineMode)
logger.withFields({
version: config.version,
viewerListenHost: config.viewerListenHost,
viewerListenPort: config.viewerListenPort,
viewerOnlineMode: config.viewerOnlineMode,
botListenHost: config.botListenHost,
botListenPort: config.botListenPort,
upstreamHost: config.upstreamHost,
upstreamPort: config.upstreamPort,
}).log('Minecraft hub started')
return {
close: async () => {
for (const session of sessions) {
if (!session.downstream.ended)
session.downstream.end('hub_shutdown')
if (session.target && !session.target.ended)
session.target.end('hub_shutdown')
}
if (!target.ended)
target.end('hub_shutdown')
viewerServer.close()
botServer.close()
if (rawDumpStream)
rawDumpStream.end()
logger.log('Minecraft hub stopped')
},
}
}
+24
View File
@@ -0,0 +1,24 @@
import process, { exit } from 'node:process'
import { loadConfig } from './config'
import { startHub } from './hub'
import { initLogger, useLogger } from './logger'
async function main() {
initLogger()
const logger = useLogger('minecraft-hub')
const config = loadConfig()
const hub = await startHub(config)
process.on('SIGINT', () => {
logger.log('Received SIGINT, shutting down...')
void hub.close()
exit(0)
})
}
main().catch((error: Error) => {
useLogger('minecraft-hub').errorWithError('Fatal error', error)
exit(1)
})
+17
View File
@@ -0,0 +1,17 @@
import { Format, LogLevel, setGlobalFormat, setGlobalLogLevel, useLogg } from '@guiiai/logg'
let initialized = false
export function initLogger() {
if (initialized)
return
setGlobalLogLevel(LogLevel.Debug)
setGlobalFormat(Format.Pretty)
useLogg('minecraft-hub').useGlobalConfig().log('Logger initialized')
initialized = true
}
export function useLogger(scope = 'minecraft-hub') {
return useLogg(scope).useGlobalConfig()
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}