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

251 lines
6.7 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 } = require('./lib/invite')
/**
* Flying Jib Bare application shell.
* - Optional pear-runtime worker for OTA (hello-pear-bare pattern)
* - Squid + HyperDHT tunnels run in this process under Bare (ADR-0013)
*/
module.exports = class App extends ReadyResource {
constructor({ dir, appPath, updates, version, upgrade, name }) {
super()
this.dir = dir
this.appPath = appPath
this.updates = updates
this.version = version
this.upgrade = upgrade
this.name = name
this.IPC = null
this.pipe = null
this.squid = null
this.tunnelHost = null
this.tunnelClient = null
this.activeWorld = null
this._shuttingDown = false
}
_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() {
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
}
this.activeWorld = null
const pipe = this.pipe
const IPC = this.IPC
this.pipe = null
this.IPC = null
pipe?.destroy()
IPC?.destroy()
}
_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)
}
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.activeWorld = meta
return this.squid.status
}
/**
* Start Squid + HyperDHT host tunnel; return status + invite string.
* @param {{ world: string, port?: number, version?: string }} opts
*/
async hostWorld(opts) {
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 tunnel')
})
this.tunnelHost.on('error', (err) => this.emit('error', err))
await this.tunnelHost.ready()
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,
invite
}
}
/**
* Join a private world via fj1. invite (client tunnel only; no local Squid).
* @param {{ invite: string, localPort?: number }} opts
*/
async joinWorld(opts) {
if (this.tunnelClient) {
throw new Error('Already joined a remote world; stop first')
}
const inv = decodeInvite(opts.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()
return {
invite: inv,
tunnel: this.tunnelClient.status
}
}
async stopWorld() {
let stopped = false
if (this.tunnelClient) {
await this.tunnelClient.close().catch(() => {})
this.tunnelClient = null
stopped = true
}
if (this.tunnelHost) {
await this.tunnelHost.close().catch(() => {})
this.tunnelHost = null
stopped = true
}
if (this.squid) {
await this.squid.close().catch(() => {})
this.squid = null
stopped = true
}
this.activeWorld = null
return stopped
}
getStatus() {
return {
storage: this.dir,
world: this.activeWorld,
squid: this.squid ? this.squid.status : { running: false },
tunnelHost: this.tunnelHost ? this.tunnelHost.status : null,
tunnelClient: this.tunnelClient ? this.tunnelClient.status : null
}
}
async exit(code = 0) {
if (this._shuttingDown) return
this._shuttingDown = true
if (typeof Bare !== 'undefined') Bare.exitCode = code
try {
await this.stopWorld()
} catch {
// ignore
}
await this.close()
}
}