Files
flying-jib/app.js
T
2026-07-30 23:57:14 -04:00

654 lines
20 KiB
JavaScript

'use strict'
const path = require('path')
const FramedStream = require('framed-stream')
const PearRuntime = require('pear-runtime')
const ReadyResource = require('ready-resource')
const { SquidManager } = require('./lib/squid-manager')
const { createWorld, listWorlds, 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 } = require('./lib/invite')
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 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 {
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) {
this.handoffs.set(m.username, m.handoff)
this.emit(
'message',
`[mesh] pending handoff for ${m.username} (${(m.handoff.inventory || []).length} items)`
)
}
}
/**
* 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}`
)
this.emit('handoff-applied', { username: player.username, result: r })
})
.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 = encodeInvite({
type: 'private-world',
worldKey: keys.publicKeyZ32,
cap: keys.capZ32,
name: this.activeWorld.name,
mcVersion: this.activeWorld.version,
portHint: squidStatus.port
})
return {
squid: squidStatus,
tunnel: this.tunnelHost.status,
peers: this.peerSession ? this.peerSession.peerList : [],
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
}
}
/**
* 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,
kickOnMigrate: true,
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.
* @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
if (
this.mcUsername &&
evt.player.username &&
evt.player.username.toLowerCase() === this.mcUsername.toLowerCase()
) {
this._switchToNeighborRegion(evt).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.
* @param {object} evt
*/
async _switchToNeighborRegion(evt) {
if (this._migrating) return
this._migrating = true
try {
const neighbor = evt.neighbor
if (!neighbor || !neighbor.worldKey) {
throw new Error('migrate missing neighbor.worldKey')
}
const publicKey = decodeKey(neighbor.worldKey)
const cap = neighbor.cap ? decodeKey(neighbor.cap) : null
const preferPort =
(this.tunnelClient && this.tunnelClient.status.localPort) ||
(this.squid && this.squid.status.port) ||
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
}
// If we were hosting the old region, keep Squid+host tunnel for others;
// local player uses a client tunnel on preferPort+1 when port still taken by Squid
let localPort = preferPort
if (this.squid && this.squid.status.running) {
localPort = preferPort === 25565 ? 25566 : preferPort + 1
}
this.tunnelClient = new WorldTunnelClient({
publicKey,
cap,
localPort
})
this.tunnelClient.on('error', (err) => this.emit('error', err))
await this.tunnelClient.ready()
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
this.emit(
'message',
`[mesh] switched tunnel → ${neighbor.name || neighbor.regionId} @ 127.0.0.1:${port}`
)
this.emit(
'message',
`Connect / reconnect Java Edition to 127.0.0.1:${port} (spawn ~ ${JSON.stringify(evt.mapped)})`
)
this.emit('migrated', {
neighbor,
localPort: port,
mapped: evt.mapped,
handoff: evt.handoff || null
})
} finally {
this._migrating = false
}
}
_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()
}
}