'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(' 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() } }