984 lines
31 KiB
JavaScript
984 lines
31 KiB
JavaScript
'use strict'
|
|
|
|
const path = require('path')
|
|
const FramedStream = require('framed-stream')
|
|
const ReadyResource = require('ready-resource')
|
|
const { SquidManager } = require('./lib/squid-manager')
|
|
const { createWorld, listWorlds, getWorld, resolveWorldPaths } = require('./lib/worlds')
|
|
const { worldsRoot } = require('./lib/paths')
|
|
const { ensureDir } = require('./lib/worlds')
|
|
const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel')
|
|
const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys')
|
|
const {
|
|
encodeInvite,
|
|
decodeInvite,
|
|
decodeKey,
|
|
encodeKey,
|
|
mintPrivateWorldInvite
|
|
} = require('./lib/invite')
|
|
const { rotateCap, revokeCap, loadRevocations, isCapRevoked } = require('./lib/cap-store')
|
|
const { redactInvite } = require('./lib/secrets')
|
|
const { PeerSession } = require('./lib/peer-session')
|
|
const { MeshRegistry, listLocalMeshes } = require('./lib/mesh-registry')
|
|
const { borderAction } = require('./lib/border')
|
|
const { HandoffStore, applyHandoff } = require('./lib/player-handoff')
|
|
const {
|
|
formatMigrateFailure,
|
|
classifyMigrateError,
|
|
withTimeout,
|
|
snapshotGuestTunnel,
|
|
MIGRATE_TUNNEL_TIMEOUT_MS
|
|
} = require('./lib/migrate')
|
|
const {
|
|
preferStableLocalPort,
|
|
buildReconnectHint,
|
|
formatKickReason
|
|
} = require('./lib/reconnect')
|
|
const crypto = require('hypercore-crypto')
|
|
|
|
/**
|
|
* Flying Jib Bare application shell.
|
|
* - Optional pear-runtime worker for OTA (hello-pear-bare pattern)
|
|
* - Squid + HyperDHT tunnels + Protomux peer session + mesh registry (ADR-0013)
|
|
*/
|
|
module.exports = class App extends ReadyResource {
|
|
constructor({ dir, appPath, updates, version, upgrade, name, displayName, mcUsername }) {
|
|
super()
|
|
this.dir = dir
|
|
this.appPath = appPath
|
|
this.updates = updates
|
|
this.version = version
|
|
this.upgrade = upgrade
|
|
this.name = name
|
|
this.displayName = displayName || 'player'
|
|
this.mcUsername = mcUsername || displayName || null
|
|
this.IPC = null
|
|
this.pipe = null
|
|
this.squid = null
|
|
this.tunnelHost = null
|
|
this.tunnelClient = null
|
|
this.peerSession = null
|
|
this.mesh = null
|
|
this.activeWorld = null
|
|
this._activeRegionId = null
|
|
this._migrating = false
|
|
this._shuttingDown = false
|
|
this.handoffs = new HandoffStore()
|
|
}
|
|
|
|
_open() {
|
|
ensureDir(worldsRoot(this.dir))
|
|
ensureDir(path.join(this.dir, 'corestore'))
|
|
|
|
const enableWorker =
|
|
this.updates !== false && this.upgrade && !String(this.upgrade).includes('<YOUR_KEY')
|
|
|
|
if (enableWorker) {
|
|
try {
|
|
const PearRuntime = require('pear-runtime')
|
|
this.IPC = PearRuntime.run(require.resolve('./workers/main.js'), [
|
|
String(this.updates),
|
|
this.version,
|
|
this.upgrade,
|
|
this.name,
|
|
this.dir,
|
|
this.appPath || ''
|
|
])
|
|
this.pipe = new FramedStream(this.IPC)
|
|
this.pipe.on('data', (data) => this._onWorkerMessage(data))
|
|
this.pipe.on('error', (err) => this.emit('error', err))
|
|
this.IPC.on('error', (err) => this.emit('error', err))
|
|
} catch (err) {
|
|
this.emit('message', `[worker] not started: ${err.message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
async _close() {
|
|
await this._teardownSession()
|
|
const pipe = this.pipe
|
|
const IPC = this.IPC
|
|
this.pipe = null
|
|
this.IPC = null
|
|
pipe?.destroy()
|
|
IPC?.destroy()
|
|
}
|
|
|
|
async _teardownSession() {
|
|
if (this.peerSession) {
|
|
await this.peerSession.close().catch(() => {})
|
|
this.peerSession = null
|
|
}
|
|
if (this.tunnelClient) {
|
|
await this.tunnelClient.close().catch(() => {})
|
|
this.tunnelClient = null
|
|
}
|
|
if (this.tunnelHost) {
|
|
await this.tunnelHost.close().catch(() => {})
|
|
this.tunnelHost = null
|
|
}
|
|
if (this.squid) {
|
|
await this.squid.close().catch(() => {})
|
|
this.squid = null
|
|
}
|
|
// mesh stays open until exit unless stopMesh called
|
|
this.activeWorld = null
|
|
}
|
|
|
|
async stopMesh() {
|
|
if (this.mesh) {
|
|
await this.mesh.close().catch(() => {})
|
|
this.mesh = null
|
|
}
|
|
}
|
|
|
|
_onWorkerMessage(data) {
|
|
const message = data.toString()
|
|
if (message === 'updating') {
|
|
this.emit('updating')
|
|
return
|
|
}
|
|
if (message === 'updated') {
|
|
this.emit('updated')
|
|
this._sendWorker('pear:applyUpdate')
|
|
return
|
|
}
|
|
if (message === 'pear:updateApplied') {
|
|
this.emit('update-applied')
|
|
return
|
|
}
|
|
this.emit('message', message)
|
|
}
|
|
|
|
_sendWorker(message) {
|
|
if (this.pipe) this.pipe.write(message)
|
|
}
|
|
|
|
async _startPeerSession(worldPublicKey) {
|
|
if (this.peerSession) {
|
|
await this.peerSession.close().catch(() => {})
|
|
this.peerSession = null
|
|
}
|
|
this.peerSession = new PeerSession({
|
|
worldPublicKey,
|
|
displayName: this.displayName
|
|
})
|
|
this.peerSession.on('chat', (m) => this.emit('chat', m))
|
|
this.peerSession.on('presence', (m) => this.emit('presence', m))
|
|
this.peerSession.on('peers', (list) => this.emit('peers', list))
|
|
this.peerSession.on('peer-join', (m) => this.emit('peer-join', m))
|
|
this.peerSession.on('peer-leave', (m) => this.emit('peer-leave', m))
|
|
this.peerSession.on('error', (err) => this.emit('error', err))
|
|
this.peerSession.on('control', (m) => this._onPeerControl(m))
|
|
this.peerSession.on('migrate', (m) => {
|
|
// also handled in _wireGuestMigrate for tunnel switch
|
|
})
|
|
await this.peerSession.ready()
|
|
this.emit('message', `Peer session ready (id ${this.peerSession.peerId})`)
|
|
}
|
|
|
|
_onPeerControl(m) {
|
|
if (!m || m.local) return
|
|
if (m.type === 'handoff-apply' && m.username && m.handoff) {
|
|
const stored = this.handoffs.set(m.username, m.handoff)
|
|
if (!stored.ok) {
|
|
this.emit(
|
|
'message',
|
|
`[mesh] rejected handoff for ${m.username}: ${stored.reason || 'failed'}` +
|
|
(m.handoff.id ? ` id=${m.handoff.id.slice(0, 8)}…` : '')
|
|
)
|
|
// Tell guest we already applied / rejected so they do not retry blindly
|
|
if (this.peerSession) {
|
|
this.peerSession.sendControl({
|
|
type: 'handoff-ack',
|
|
ok: false,
|
|
reason: stored.reason || 'rejected',
|
|
username: m.username,
|
|
handoffId: m.handoff.id || null
|
|
})
|
|
}
|
|
return
|
|
}
|
|
this.emit(
|
|
'message',
|
|
`[mesh] pending handoff for ${m.username} (${(m.handoff.inventory || []).length} items)` +
|
|
(m.handoff.id ? ` id=${String(m.handoff.id).slice(0, 8)}…` : '')
|
|
)
|
|
// Phase ack: destination accepted prepare payload into store
|
|
if (this.peerSession) {
|
|
this.peerSession.sendControl({
|
|
type: 'handoff-ack',
|
|
ok: true,
|
|
phase: 'prepared',
|
|
username: m.username,
|
|
handoffId: m.handoff.id || null
|
|
})
|
|
}
|
|
this.emit('handoff-prepared', {
|
|
username: m.username,
|
|
handoffId: m.handoff.id || null
|
|
})
|
|
return
|
|
}
|
|
if (m.type === 'handoff-ack') {
|
|
this.emit('handoff-ack', m)
|
|
this.emit(
|
|
'message',
|
|
`[mesh] handoff-ack ${m.ok ? 'ok' : 'fail'} ${m.username || ''} ${m.phase || m.reason || ''}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When a player spawns on this Squid, apply any pending mesh handoff.
|
|
*/
|
|
_wireHandoffOnSpawn() {
|
|
if (!this.squid || !this.squid.serv) return
|
|
const serv = this.squid.serv
|
|
if (serv._fjHandoffWired) return
|
|
serv._fjHandoffWired = true
|
|
serv.on('newPlayer', (player) => {
|
|
player.on('spawned', () => {
|
|
const handoff = this.handoffs.take(player.username)
|
|
if (!handoff) return
|
|
applyHandoff(player, serv, handoff)
|
|
.then((r) => {
|
|
this.emit(
|
|
'message',
|
|
`[mesh] handoff applied for ${player.username}: slots=${r.slots || 0}` +
|
|
(r.handoffId ? ` id=${String(r.handoffId).slice(0, 8)}…` : '')
|
|
)
|
|
this.emit('handoff-applied', {
|
|
username: player.username,
|
|
result: r,
|
|
handoffId: r.handoffId || handoff.id || null
|
|
})
|
|
// Commit ack to guest/session
|
|
if (this.peerSession) {
|
|
this.peerSession.sendControl({
|
|
type: 'handoff-ack',
|
|
ok: !!r.ok,
|
|
phase: 'committed',
|
|
username: player.username,
|
|
handoffId: r.handoffId || handoff.id || null
|
|
})
|
|
}
|
|
})
|
|
.catch((err) => this.emit('error', err))
|
|
})
|
|
})
|
|
}
|
|
|
|
listWorlds() {
|
|
return listWorlds(this.dir)
|
|
}
|
|
|
|
createWorld(opts) {
|
|
return createWorld(this.dir, opts)
|
|
}
|
|
|
|
/**
|
|
* Start Squid for a world (loopback only).
|
|
* @param {{ world: string, port?: number, version?: string }} opts
|
|
*/
|
|
async startWorld(opts) {
|
|
if (this.squid) {
|
|
throw new Error('A world is already running; stop it first')
|
|
}
|
|
const { meta, anvil } = resolveWorldPaths(this.dir, opts.world)
|
|
const port = opts.port || 25565
|
|
const version = opts.version || meta.version
|
|
|
|
this.squid = new SquidManager({
|
|
worldFolder: anvil,
|
|
port,
|
|
version,
|
|
motd: meta.motd || `Flying Jib — ${meta.name}`,
|
|
logging: false
|
|
})
|
|
|
|
this.squid.on('error', (err) => this.emit('error', err))
|
|
this.squid.on('listening', (p) => {
|
|
this.emit('message', `Squid listening on 127.0.0.1:${p}`)
|
|
})
|
|
|
|
await this.squid.ready()
|
|
this._wireHandoffOnSpawn()
|
|
this.activeWorld = meta
|
|
return this.squid.status
|
|
}
|
|
|
|
/**
|
|
* Start Squid + HyperDHT host tunnel + peer chat/presence.
|
|
* @param {{ world: string, port?: number, version?: string, displayName?: string, meshInvite?: string, meshKey?: string, enroll?: object }} opts
|
|
*/
|
|
async hostWorld(opts) {
|
|
if (opts.displayName) this.displayName = opts.displayName
|
|
if (opts.mcUsername) this.mcUsername = opts.mcUsername
|
|
const squidStatus = await this.startWorld(opts)
|
|
const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id)
|
|
|
|
this.tunnelHost = new WorldTunnelHost({
|
|
localPort: squidStatus.port,
|
|
seed: keys.seed,
|
|
cap: keys.cap
|
|
})
|
|
this.tunnelHost.on('connection', () => {
|
|
this.emit('message', 'Remote peer connected via MC tunnel')
|
|
})
|
|
this.tunnelHost.on('error', (err) => this.emit('error', err))
|
|
await this.tunnelHost.ready()
|
|
|
|
this.squid.on('migrate', (evt) => this._onHostMigrate(evt))
|
|
this.squid.on('border', (evt) => this.emit('border', evt))
|
|
|
|
await this._startPeerSession(keys.publicKey)
|
|
|
|
// Optional: open mesh + enroll so border plugin activates
|
|
let region = null
|
|
if (opts.meshInvite || opts.meshKey || opts.createMesh) {
|
|
if (opts.createMesh) {
|
|
await this.createMesh({ name: opts.meshName || 'mesh' })
|
|
} else {
|
|
await this.openMesh({ invite: opts.meshInvite, key: opts.meshKey })
|
|
}
|
|
if (this.mesh && this.mesh.writable) {
|
|
region = await this.enrollWorld({
|
|
world: this.activeWorld.id,
|
|
...(opts.enroll || {})
|
|
})
|
|
}
|
|
}
|
|
|
|
const invite = mintPrivateWorldInvite(
|
|
{
|
|
worldKey: keys.publicKeyZ32,
|
|
cap: keys.capZ32,
|
|
name: this.activeWorld.name,
|
|
mcVersion: this.activeWorld.version,
|
|
portHint: squidStatus.port,
|
|
role: opts.role || 'player'
|
|
},
|
|
{
|
|
ttl: opts.ttl,
|
|
expires: opts.expires,
|
|
noExpire: opts.noExpire
|
|
}
|
|
)
|
|
|
|
return {
|
|
squid: squidStatus,
|
|
tunnel: this.tunnelHost.status,
|
|
peers: this.peerSession ? this.peerSession.peerList : [],
|
|
invite,
|
|
inviteMeta: decodeInvite(invite),
|
|
region,
|
|
mesh: this.mesh
|
|
? { key: this.mesh.keyZ32, invite: this.mesh.keyZ32 && encodeInvite({ type: 'mesh', meshKey: this.mesh.keyZ32, name: this.mesh.name }) }
|
|
: null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mint a private-world invite for an existing world (no Squid start required).
|
|
* @param {{ world: string, ttl?: string|number, noExpire?: boolean, role?: string, portHint?: number }} opts
|
|
*/
|
|
mintInvite(opts) {
|
|
const meta = this._requireWorld(opts.world)
|
|
const keys = loadOrCreateTunnelKeys(this.dir, meta.id)
|
|
if (isCapRevoked(this.dir, meta.id, keys.cap)) {
|
|
throw new Error('Active tunnel cap is on the revoke ledger — run rotate-cap first')
|
|
}
|
|
const invite = mintPrivateWorldInvite(
|
|
{
|
|
worldKey: keys.publicKeyZ32,
|
|
cap: keys.capZ32,
|
|
name: meta.name,
|
|
mcVersion: meta.version,
|
|
portHint: opts.portHint != null ? Number(opts.portHint) : 25565,
|
|
role: opts.role || 'player'
|
|
},
|
|
{
|
|
ttl: opts.ttl,
|
|
expires: opts.expires,
|
|
noExpire: opts.noExpire
|
|
}
|
|
)
|
|
const body = decodeInvite(invite)
|
|
this.emit(
|
|
'message',
|
|
`[invite] minted for ${meta.id} role=${body.role || 'player'}` +
|
|
(body.expires ? ` expires=${new Date(body.expires).toISOString()}` : ' expires=never') +
|
|
` ${redactInvite(invite)}`
|
|
)
|
|
return { invite, inviteMeta: body, world: meta, publicKeyZ32: keys.publicKeyZ32 }
|
|
}
|
|
|
|
/**
|
|
* Rotate tunnel capability for a world (invalidates all outstanding private invites).
|
|
* If currently hosting this world, restarts tunnel host with the new cap.
|
|
* @param {{ world: string }} opts
|
|
*/
|
|
async rotateWorldCap(opts) {
|
|
const meta = this._requireWorld(opts.world)
|
|
const rotated = rotateCap(this.dir, meta.id)
|
|
this.emit(
|
|
'message',
|
|
`[security] rotated tunnel cap for ${meta.id} (old invites invalid) fp=${rotated.revokedFp.slice(0, 12)}…`
|
|
)
|
|
|
|
// Live host: rebind tunnel with new cap (same DHT key)
|
|
if (this.tunnelHost && this.activeWorld && this.activeWorld.id === meta.id) {
|
|
const port = this.squid && this.squid.status && this.squid.status.port
|
|
const keys = loadOrCreateTunnelKeys(this.dir, meta.id)
|
|
await this.tunnelHost.close().catch(() => {})
|
|
this.tunnelHost = new WorldTunnelHost({
|
|
localPort: port,
|
|
seed: keys.seed,
|
|
cap: keys.cap
|
|
})
|
|
this.tunnelHost.on('connection', () => {
|
|
this.emit('message', 'Remote peer connected via MC tunnel')
|
|
})
|
|
this.tunnelHost.on('error', (err) => this.emit('error', err))
|
|
await this.tunnelHost.ready()
|
|
this.emit('message', '[security] tunnel host reloaded with new capability')
|
|
}
|
|
|
|
return rotated
|
|
}
|
|
|
|
/**
|
|
* Record a cap or invite as revoked (audit). Does not change active cap unless it matches.
|
|
* Prefer rotateWorldCap to invalidate live access.
|
|
*/
|
|
revokeWorldCap(opts) {
|
|
const meta = this._requireWorld(opts.world)
|
|
const r = revokeCap(this.dir, meta.id, opts.capOrInvite, opts.reason || 'manual')
|
|
this.emit(
|
|
'message',
|
|
`[security] revoke ${r.already ? 'already-listed' : 'recorded'} fp=${r.fp.slice(0, 12)}… for ${meta.id}`
|
|
)
|
|
return r
|
|
}
|
|
|
|
listRevocations(worldName) {
|
|
const meta = this._requireWorld(worldName)
|
|
return loadRevocations(this.dir, meta.id)
|
|
}
|
|
|
|
_requireWorld(nameOrId) {
|
|
try {
|
|
return getWorld(this.dir, nameOrId)
|
|
} catch {
|
|
const meta = listWorlds(this.dir).find(
|
|
(w) => w.name && w.name.toLowerCase() === String(nameOrId || '').toLowerCase()
|
|
)
|
|
if (!meta) throw new Error(`World not found: ${nameOrId}`)
|
|
return meta
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Join a private world via fj1. invite (tunnel + peer session).
|
|
* @param {{ invite: string, localPort?: number, displayName?: string }} opts
|
|
*/
|
|
async joinWorld(opts) {
|
|
if (this.tunnelClient) {
|
|
throw new Error('Already joined a remote world; stop first')
|
|
}
|
|
if (opts.displayName) this.displayName = opts.displayName
|
|
if (opts.mcUsername) this.mcUsername = opts.mcUsername
|
|
const inv = decodeInvite(opts.invite)
|
|
if (inv.type !== 'private-world') {
|
|
throw new Error('join expects a private-world fj1. invite')
|
|
}
|
|
const publicKey = decodeKey(inv.worldKey)
|
|
const cap = decodeKey(inv.cap)
|
|
|
|
this.tunnelClient = new WorldTunnelClient({
|
|
publicKey,
|
|
cap,
|
|
localPort: opts.localPort != null ? Number(opts.localPort) : 0
|
|
})
|
|
this.tunnelClient.on('error', (err) => this.emit('error', err))
|
|
await this.tunnelClient.ready()
|
|
|
|
await this._startPeerSession(publicKey)
|
|
this._wireGuestMigrate()
|
|
|
|
return {
|
|
invite: inv,
|
|
tunnel: this.tunnelClient.status,
|
|
peers: this.peerSession ? this.peerSession.peerList : []
|
|
}
|
|
}
|
|
|
|
sendChat(text) {
|
|
if (!this.peerSession) throw new Error('No peer session (host or join first)')
|
|
this.peerSession.sendChat(text)
|
|
}
|
|
|
|
async stopWorld() {
|
|
await this._teardownSession()
|
|
return true
|
|
}
|
|
|
|
listMeshes() {
|
|
return listLocalMeshes(this.dir)
|
|
}
|
|
|
|
/**
|
|
* Create a new mesh (local becomes first writer).
|
|
* @param {{ name?: string }} opts
|
|
*/
|
|
async createMesh(opts = {}) {
|
|
if (this.mesh) await this.stopMesh()
|
|
this.mesh = new MeshRegistry({
|
|
storageDir: this.dir,
|
|
bootstrap: null,
|
|
name: opts.name || 'Flying Jib Mesh'
|
|
})
|
|
await this.mesh.ready()
|
|
const invite = encodeInvite({
|
|
type: 'mesh',
|
|
meshKey: this.mesh.keyZ32,
|
|
name: opts.name || 'Flying Jib Mesh'
|
|
})
|
|
return {
|
|
key: this.mesh.keyZ32,
|
|
meshId: this.mesh.meshId,
|
|
writable: this.mesh.writable,
|
|
localWriterKey: this.mesh.localWriterKey
|
|
? encodeKey(this.mesh.localWriterKey)
|
|
: null,
|
|
invite
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open/join mesh by key or fj1. mesh invite.
|
|
* @param {{ key?: string, invite?: string, name?: string }} opts
|
|
*/
|
|
async openMesh(opts) {
|
|
if (this.mesh) await this.stopMesh()
|
|
let key = opts.key
|
|
let name = opts.name
|
|
if (opts.invite) {
|
|
const inv = decodeInvite(opts.invite)
|
|
if (inv.type !== 'mesh') throw new Error('Invite is not a mesh invite')
|
|
key = inv.meshKey
|
|
name = inv.name || name
|
|
}
|
|
if (!key) throw new Error('openMesh requires key or mesh invite')
|
|
this.mesh = new MeshRegistry({
|
|
storageDir: this.dir,
|
|
bootstrap: key,
|
|
name: name || 'mesh'
|
|
})
|
|
await this.mesh.ready()
|
|
return {
|
|
key: this.mesh.keyZ32,
|
|
meshId: this.mesh.meshId,
|
|
writable: this.mesh.writable,
|
|
localWriterKey: this.mesh.localWriterKey
|
|
? encodeKey(this.mesh.localWriterKey)
|
|
: null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Admit a peer's writer key (must be mesh writer).
|
|
* @param {string} writerKeyZ32
|
|
*/
|
|
async meshAddWriter(writerKeyZ32) {
|
|
if (!this.mesh) throw new Error('No mesh open')
|
|
await this.mesh.addWriter(writerKeyZ32)
|
|
return { ok: true }
|
|
}
|
|
|
|
/**
|
|
* Enroll active (or named) world into open mesh.
|
|
* @param {{ world?: string, minX?: number, maxX?: number, minZ?: number, maxZ?: number, offsetX?: number, offsetZ?: number }} opts
|
|
*/
|
|
async enrollWorld(opts = {}) {
|
|
if (!this.mesh) throw new Error('No mesh open — create-mesh or open-mesh first')
|
|
const worldName = opts.world || (this.activeWorld && this.activeWorld.id)
|
|
if (!worldName) throw new Error('enroll requires world name or active hosted world')
|
|
const { meta } = resolveWorldPaths(this.dir, worldName)
|
|
const keys = loadOrCreateTunnelKeys(this.dir, meta.id)
|
|
const minX = opts.minX != null ? Number(opts.minX) : 0
|
|
const maxX = opts.maxX != null ? Number(opts.maxX) : 9999
|
|
const minZ = opts.minZ != null ? Number(opts.minZ) : 0
|
|
const maxZ = opts.maxZ != null ? Number(opts.maxZ) : 9999
|
|
const region = await this.mesh.enroll({
|
|
regionId: meta.id + '-' + crypto.randomBytes(4).toString('hex'),
|
|
worldKey: keys.publicKeyZ32,
|
|
// Mesh members share tunnel caps (mesh ACL); private fj1. invites still use same keys
|
|
cap: keys.capZ32,
|
|
ownerKey: this.mesh.localWriterKey ? encodeKey(this.mesh.localWriterKey) : null,
|
|
bounds: { minX, maxX, minZ, maxZ },
|
|
offset: {
|
|
x: opts.offsetX != null ? Number(opts.offsetX) : minX,
|
|
z: opts.offsetZ != null ? Number(opts.offsetZ) : minZ
|
|
},
|
|
mcVersion: meta.version,
|
|
name: meta.name,
|
|
portalPolicy: 'teleport'
|
|
})
|
|
this._activeRegionId = region.regionId
|
|
// If currently hosting this world, enable border plugin
|
|
if (this.squid && this.activeWorld && this.activeWorld.id === meta.id) {
|
|
await this._enableMeshBorder(region)
|
|
}
|
|
return region
|
|
}
|
|
|
|
/**
|
|
* Enable fj-mesh-border on the running Squid for an enrolled region.
|
|
* @param {object} region
|
|
*/
|
|
async _enableMeshBorder(region) {
|
|
if (!this.squid || !this.mesh) return
|
|
const self = this
|
|
const cfg = {
|
|
enabled: true,
|
|
regionId: region.regionId,
|
|
bounds: region.bounds,
|
|
offset: region.offset,
|
|
margin: 16,
|
|
intervalMs: 400,
|
|
// Give Protomux migrate + guest tunnel switch a head start before Java drop
|
|
kickOnMigrate: true,
|
|
kickDelayMs: 1200,
|
|
getKickReason: (evt) =>
|
|
formatKickReason({
|
|
regionName: evt.neighbor && evt.neighbor.name,
|
|
regionId: evt.neighbor && evt.neighbor.regionId
|
|
}),
|
|
getRegions: async () => self.mesh.listRegions(),
|
|
onBorder: (evt) => self.emit('border', evt),
|
|
onMigrate: (evt) => self._onHostMigrate(evt)
|
|
}
|
|
this.squid.setMeshBorder(cfg)
|
|
this.emit('message', `Mesh border active for region ${region.regionId}`)
|
|
}
|
|
|
|
/**
|
|
* Host-side: player crossed out of region — notify peers and log.
|
|
* Migrate control is sent first (plugin delays kick ~1.2s for soft-reconnect window).
|
|
* @param {object} evt from fj-mesh-border
|
|
*/
|
|
_onHostMigrate(evt) {
|
|
this.emit('migrate', evt)
|
|
this.emit(
|
|
'message',
|
|
`[mesh] migrate ${evt.player.username} → ${evt.neighbor.name || evt.neighbor.regionId}` +
|
|
(evt.handoff ? ` (handoff ${evt.handoff.inventory?.length || 0} items)` : '')
|
|
)
|
|
if (this.peerSession) {
|
|
this.peerSession.sendControl({
|
|
type: 'migrate',
|
|
username: evt.player.username,
|
|
fromRegionId: evt.fromRegionId,
|
|
neighbor: evt.neighbor,
|
|
mapped: evt.mapped,
|
|
player: evt.player,
|
|
handoff: evt.handoff || null
|
|
})
|
|
}
|
|
// Local Java client (same machine as host) also needs to switch tunnel
|
|
// Start immediately so tunnel is up before kick fires (~1.2s later)
|
|
if (
|
|
this.mcUsername &&
|
|
evt.player.username &&
|
|
evt.player.username.toLowerCase() === this.mcUsername.toLowerCase()
|
|
) {
|
|
this._switchToNeighborRegion(evt)
|
|
.then(() => {
|
|
// Refresh kick reason with known port if border plugin supports live cfg
|
|
const port =
|
|
this.tunnelClient && this.tunnelClient.status && this.tunnelClient.status.localPort
|
|
if (port && this.squid) {
|
|
const cfg =
|
|
this.squid.fjMeshBorder ||
|
|
(this.squid.serv && this.squid.serv.fjMeshBorder)
|
|
if (cfg) {
|
|
cfg.getKickReason = (e) =>
|
|
formatKickReason({
|
|
regionName: e.neighbor && e.neighbor.name,
|
|
regionId: e.neighbor && e.neighbor.regionId,
|
|
port
|
|
})
|
|
// Keep plugin config pointer live on serv
|
|
if (this.squid.serv) this.squid.serv.fjMeshBorder = cfg
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => this.emit('error', err))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guest/local: stop current world tunnel and open tunnel to neighbor region.
|
|
* Squid host process is left running if we are the region host for others.
|
|
* On failure: clear offline UX, restore previous tunnel when possible — never
|
|
* silent master failover (ADR-0008 / agent RULES §1).
|
|
* @param {object} evt
|
|
*/
|
|
async _switchToNeighborRegion(evt) {
|
|
if (this._migrating) return
|
|
this._migrating = true
|
|
const previous = snapshotGuestTunnel(this.tunnelClient)
|
|
const previousRegionId = this._activeRegionId
|
|
let tornDown = false
|
|
try {
|
|
const neighbor = evt.neighbor
|
|
if (!neighbor || !neighbor.worldKey) {
|
|
const msg = formatMigrateFailure({
|
|
regionId: neighbor && neighbor.regionId,
|
|
regionName: neighbor && neighbor.name,
|
|
kind: 'missing-key'
|
|
})
|
|
this.emit('message', `[mesh] ${msg.split('\n')[0]}`)
|
|
this.emit('migrate-failed', {
|
|
neighbor,
|
|
kind: 'missing-key',
|
|
message: msg,
|
|
handoff: evt.handoff || null,
|
|
restored: false
|
|
})
|
|
return
|
|
}
|
|
const publicKey = decodeKey(neighbor.worldKey)
|
|
const cap = neighbor.cap ? decodeKey(neighbor.cap) : null
|
|
const previousLocalPort =
|
|
(this.tunnelClient && this.tunnelClient.status && this.tunnelClient.status.localPort) ||
|
|
null
|
|
const squidPort =
|
|
this.squid && this.squid.status && this.squid.status.running
|
|
? this.squid.status.port
|
|
: null
|
|
const portPick = preferStableLocalPort({
|
|
previousLocalPort,
|
|
squidPort,
|
|
fallback: 25565
|
|
})
|
|
|
|
// Tear down guest tunnel / peer session for old world (keep Squid if hosting)
|
|
if (this.peerSession) {
|
|
await this.peerSession.close().catch(() => {})
|
|
this.peerSession = null
|
|
}
|
|
if (this.tunnelClient) {
|
|
await this.tunnelClient.close().catch(() => {})
|
|
this.tunnelClient = null
|
|
}
|
|
tornDown = true
|
|
|
|
this.emit(
|
|
'message',
|
|
`[mesh] connecting to neighbor ${neighbor.name || neighbor.regionId} (timeout ${MIGRATE_TUNNEL_TIMEOUT_MS / 1000}s)…`
|
|
)
|
|
|
|
this.tunnelClient = new WorldTunnelClient({
|
|
publicKey,
|
|
cap,
|
|
localPort: portPick.localPort
|
|
})
|
|
this.tunnelClient.on('error', (err) => this.emit('error', err))
|
|
await withTimeout(
|
|
this.tunnelClient.ready(),
|
|
MIGRATE_TUNNEL_TIMEOUT_MS,
|
|
'neighbor tunnel'
|
|
)
|
|
|
|
await this._startPeerSession(publicKey)
|
|
this._wireGuestMigrate()
|
|
|
|
// Deliver inventory handoff to neighbor host via control plane
|
|
if (evt.handoff && this.peerSession) {
|
|
this.peerSession.sendControl({
|
|
type: 'handoff-apply',
|
|
username: evt.player?.username || evt.username || evt.handoff.username,
|
|
handoff: evt.handoff
|
|
})
|
|
}
|
|
|
|
this._activeRegionId = neighbor.regionId
|
|
const port = this.tunnelClient.status.localPort
|
|
const samePort = previousLocalPort != null && port === previousLocalPort
|
|
const hint = buildReconnectHint({
|
|
host: '127.0.0.1',
|
|
port,
|
|
regionId: neighbor.regionId,
|
|
regionName: neighbor.name,
|
|
mapped: evt.mapped,
|
|
samePort
|
|
})
|
|
this.emit(
|
|
'message',
|
|
`[mesh] switched tunnel → ${neighbor.name || neighbor.regionId} @ ${hint.address}` +
|
|
(samePort ? ' (same local port)' : '')
|
|
)
|
|
this.emit('reconnect-hint', hint)
|
|
this.emit('migrated', {
|
|
neighbor,
|
|
localPort: port,
|
|
mapped: evt.mapped,
|
|
handoff: evt.handoff || null,
|
|
samePort,
|
|
reconnect: hint
|
|
})
|
|
} catch (err) {
|
|
// Hold handoff locally so a later successful migrate can re-send
|
|
if (evt.handoff && evt.handoff.username) {
|
|
const stashed = this.handoffs.set(evt.handoff.username, evt.handoff)
|
|
if (!stashed.ok) {
|
|
this.emit(
|
|
'message',
|
|
`[mesh] could not stash handoff for retry: ${stashed.reason || 'failed'}`
|
|
)
|
|
}
|
|
}
|
|
if (this.tunnelClient) {
|
|
await this.tunnelClient.close().catch(() => {})
|
|
this.tunnelClient = null
|
|
}
|
|
if (this.peerSession) {
|
|
await this.peerSession.close().catch(() => {})
|
|
this.peerSession = null
|
|
}
|
|
|
|
let restored = false
|
|
if (tornDown && previous) {
|
|
try {
|
|
restored = await this._restoreGuestTunnel(previous, previousRegionId)
|
|
} catch (restoreErr) {
|
|
this.emit('error', restoreErr)
|
|
}
|
|
}
|
|
|
|
const kind = classifyMigrateError(err)
|
|
const msg = formatMigrateFailure({
|
|
regionId: evt.neighbor && evt.neighbor.regionId,
|
|
regionName: evt.neighbor && evt.neighbor.name,
|
|
kind,
|
|
cause: err && err.message
|
|
})
|
|
for (const line of msg.split('\n')) {
|
|
this.emit('message', `[mesh] ${line}`)
|
|
}
|
|
this.emit('migrate-failed', {
|
|
neighbor: evt.neighbor || null,
|
|
kind,
|
|
error: err,
|
|
message: msg,
|
|
handoff: evt.handoff || null,
|
|
restored,
|
|
previousPort: restored && this.tunnelClient ? this.tunnelClient.status.localPort : null
|
|
})
|
|
// Do not rethrow: callers treat this as a handled offline UX path
|
|
} finally {
|
|
this._migrating = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-open previous guest tunnel after a failed migrate (best-effort).
|
|
* @param {{ publicKey: Buffer, cap: Buffer|null, localPort: number }} snap
|
|
* @param {string|null} regionId
|
|
*/
|
|
async _restoreGuestTunnel(snap, regionId) {
|
|
if (!snap || !snap.publicKey) return false
|
|
this.tunnelClient = new WorldTunnelClient({
|
|
publicKey: snap.publicKey,
|
|
cap: snap.cap,
|
|
localPort: snap.localPort || 0
|
|
})
|
|
this.tunnelClient.on('error', (err) => this.emit('error', err))
|
|
await withTimeout(
|
|
this.tunnelClient.ready(),
|
|
MIGRATE_TUNNEL_TIMEOUT_MS,
|
|
'restore previous tunnel'
|
|
)
|
|
await this._startPeerSession(snap.publicKey)
|
|
this._wireGuestMigrate()
|
|
this._activeRegionId = regionId
|
|
const port = this.tunnelClient.status.localPort
|
|
this.emit(
|
|
'message',
|
|
`[mesh] restored previous tunnel @ 127.0.0.1:${port} (neighbor was offline)`
|
|
)
|
|
return true
|
|
}
|
|
|
|
_wireGuestMigrate() {
|
|
if (!this.peerSession) return
|
|
this.peerSession.removeAllListeners('migrate')
|
|
this.peerSession.on('migrate', (m) => {
|
|
if (m.local) return
|
|
if (
|
|
this.mcUsername &&
|
|
m.username &&
|
|
m.username.toLowerCase() === this.mcUsername.toLowerCase()
|
|
) {
|
|
this._switchToNeighborRegion(m).catch((err) => this.emit('error', err))
|
|
} else if (!this.mcUsername) {
|
|
// No filter configured — apply migrate (single-player guest)
|
|
this._switchToNeighborRegion(m).catch((err) => this.emit('error', err))
|
|
}
|
|
})
|
|
}
|
|
|
|
async listMeshRegions() {
|
|
if (!this.mesh) throw new Error('No mesh open')
|
|
return this.mesh.listRegions()
|
|
}
|
|
|
|
/**
|
|
* Border advisory for a position in the current mesh.
|
|
* @param {{ regionId: string, x: number, y?: number, z: number, margin?: number }} opts
|
|
*/
|
|
async checkBorder(opts) {
|
|
if (!this.mesh) throw new Error('No mesh open')
|
|
const regions = await this.mesh.listRegions()
|
|
const current = regions.find((r) => r.regionId === opts.regionId)
|
|
if (!current) throw new Error('region not found: ' + opts.regionId)
|
|
return borderAction(regions, current, opts.x, opts.z, opts.margin)
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
storage: this.dir,
|
|
world: this.activeWorld,
|
|
displayName: this.displayName,
|
|
squid: this.squid ? this.squid.status : { running: false },
|
|
tunnelHost: this.tunnelHost ? this.tunnelHost.status : null,
|
|
tunnelClient: this.tunnelClient ? this.tunnelClient.status : null,
|
|
peers: this.peerSession ? this.peerSession.peerList : [],
|
|
peerId: this.peerSession ? this.peerSession.peerId : null,
|
|
mesh: this.mesh
|
|
? {
|
|
key: this.mesh.keyZ32,
|
|
meshId: this.mesh.meshId,
|
|
writable: this.mesh.writable
|
|
}
|
|
: null
|
|
}
|
|
}
|
|
|
|
async exit(code = 0) {
|
|
if (this._shuttingDown) return
|
|
this._shuttingDown = true
|
|
if (typeof Bare !== 'undefined') Bare.exitCode = code
|
|
try {
|
|
await this.stopWorld()
|
|
await this.stopMesh()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
await this.close()
|
|
}
|
|
}
|