This commit is contained in:
Raven Scott
2026-07-30 23:25:19 -04:00
parent 501d1e5cfd
commit 92cb4b9554
24 changed files with 909 additions and 118 deletions
+10 -5
View File
@@ -51,13 +51,18 @@ Java Edition always connects to **localhost**. The Bare app owns Squids lifec
## Development
```sh
# Phase 1+ (Bare)
npm install # dev machine only
npm start # bare bin.mjs --no-updates
# Minecraft Java → 127.0.0.1:25565
npm install # dev machine only; postinstall patches Bare engines + imports
BARE=./node_modules/bare-runtime/bin/bare
$BARE bin.mjs create demo
$BARE bin.mjs host demo --port 25565 # print fj1. invite; local Squid
$BARE bin.mjs join 'fj1.…' --port 25566 # guest tunnel
# Minecraft Java → 127.0.0.1:<port shown>
npm test
```
See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [developer_docs/CONTRIBUTING.md](./developer_docs/CONTRIBUTING.md).
See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [living_docs/CURRENT_STATUS.md](./living_docs/CURRENT_STATUS.md).
## License
+105 -8
View File
@@ -8,11 +8,14 @@ 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 runs in this process under Bare (ADR-0013)
* - Squid + HyperDHT tunnels run in this process under Bare (ADR-0013)
*/
module.exports = class App extends ReadyResource {
constructor({ dir, appPath, updates, version, upgrade, name }) {
@@ -26,14 +29,16 @@ module.exports = class App extends ReadyResource {
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'))
// Pear OTA worker (optional — disabled when --no-updates and no upgrade key)
const enableWorker =
this.updates !== false && this.upgrade && !String(this.upgrade).includes('<YOUR_KEY')
@@ -58,10 +63,19 @@ module.exports = class App extends ReadyResource {
}
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
@@ -117,7 +131,7 @@ module.exports = class App extends ReadyResource {
port,
version,
motd: meta.motd || `Flying Jib — ${meta.name}`,
logging: true
logging: false
})
this.squid.on('error', (err) => this.emit('error', err))
@@ -130,24 +144,107 @@ module.exports = class App extends ReadyResource {
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() {
if (!this.squid) return false
await this.squid.close()
this.squid = null
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 true
return stopped
}
getStatus() {
return {
storage: this.dir,
world: this.activeWorld,
squid: this.squid ? this.squid.status : { running: false }
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()
}
}
+126 -35
View File
@@ -2,9 +2,11 @@
* Flying Jib CLI — Bare entry (ADR-0013).
*
* Usage:
* bare bin.mjs create <name> [--version 1.21.1]
* bare bin.mjs create <name>
* bare bin.mjs list
* bare bin.mjs start <name> [--port 25565] [--no-updates]
* bare bin.mjs start <name> [--port 25565]
* bare bin.mjs host <name> [--port 25565] # start + share invite
* bare bin.mjs join <invite> [--port 25565] # client tunnel
* bare bin.mjs status
*/
@@ -24,7 +26,6 @@ const appName = pkg.productName || pkg.name
const isBare = typeof Bare !== 'undefined'
const argv0 = isBare ? Bare.argv[0] : process.argv[0]
const base = path.basename(argv0)
// bare-runtime embeds as "bare"; packaged standalone uses product name
const isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0)
const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2)
@@ -58,6 +59,38 @@ function makeApp(storage, updates = false) {
})
}
/** Attach signal handlers once; re-entrant safe via app.exit */
function attachShutdown(app) {
let stopping = false
const shutdown = async (code) => {
if (stopping) return
stopping = true
console.log('\nStopping…')
try {
await app.exit(code)
} catch (err) {
console.error('[shutdown]', err.message || err)
}
exit(code)
}
process.on('SIGINT', () => {
shutdown(130)
})
process.on('SIGTERM', () => {
shutdown(143)
})
if (isBare && typeof Bare !== 'undefined' && Bare.on) {
try {
Bare.on('exit', () => {
// best-effort; async exit may not finish
})
} catch {
// ignore
}
}
return shutdown
}
const createCmd = command(
'create',
summary('Create a new local world'),
@@ -107,37 +140,16 @@ const listCmd = command(
const startCmd = command(
'start',
summary('Start local Flying Squid for a world (127.0.0.1 only)'),
summary('Start local Flying Squid (127.0.0.1 only, no P2P share)'),
arg('<name>', 'world name / id'),
flag('--port|-p [port]', 'local Minecraft port (default 25565)'),
flag('--version [ver]', 'override Minecraft version'),
flag('--no-updates', 'disable OTA updates for this run'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
// paparam: --no-updates → flags.updates === false when present
const updates = cmd.flags.updates === false || root.flags.updates === false ? false : false
const app = makeApp(storage, updates)
const app = makeApp(storage, false)
app.on('message', (m) => console.log(m))
app.on('error', (err) => console.error('[error]', err))
const shutdown = async (code) => {
console.log('\nStopping…')
try {
await app.stopWorld()
} catch {
// ignore
}
await app.exit(code)
exit(code)
}
process.on('SIGINT', () => {
shutdown(130)
})
process.on('SIGTERM', () => {
shutdown(143)
})
attachShutdown(app)
await app.ready()
try {
@@ -147,11 +159,9 @@ const startCmd = command(
version: cmd.flags.version
})
console.log('')
console.log(`${appName} world running (Bare/Pear, ADR-0013)`)
console.log(`${appName} local world running`)
console.log(` world: ${cmd.args.name}`)
console.log(` bind: ${status.host}:${status.port}`)
console.log(` listen: ${status.listen?.address}:${status.listen?.port}`)
console.log(` version:${status.version}`)
console.log('')
console.log('Connect Java Edition to:')
console.log(` ${status.host}:${status.port}`)
@@ -166,6 +176,89 @@ const startCmd = command(
}
)
const hostCmd = command(
'host',
summary('Start world and share via HyperDHT (prints fj1. invite)'),
arg('<name>', 'world name / id'),
flag('--port|-p [port]', 'local Minecraft port (default 25565)'),
flag('--version [ver]', 'override Minecraft version'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
app.on('message', (m) => console.log(m))
app.on('error', (err) => console.error('[error]', err))
attachShutdown(app)
await app.ready()
try {
const result = await app.hostWorld({
world: cmd.args.name,
port: cmd.flags.port ? Number(cmd.flags.port) : 25565,
version: cmd.flags.version
})
console.log('')
console.log(`${appName} hosting private world (P2P)`)
console.log(` world: ${cmd.args.name}`)
console.log(` local: ${result.squid.host}:${result.squid.port}`)
console.log(` tunnel: HyperDHT host`)
console.log('')
console.log('Invite (treat as secret — fj1. capability):')
console.log(result.invite)
console.log('')
console.log('Friends run:')
console.log(` flying-jib join '<invite>'`)
console.log('')
console.log('You connect Java Edition to:')
console.log(` ${result.squid.host}:${result.squid.port}`)
console.log('')
console.log('Press Ctrl+C to stop.')
await new Promise(() => {})
} catch (err) {
console.error('[host failed]', err)
await app.close()
exit(1)
}
}
)
const joinCmd = command(
'join',
summary('Join a private world from an fj1. invite'),
arg('<invite>', 'fj1. invite string'),
flag('--port|-p [port]', 'local bind port (default: ephemeral)'),
async (cmd) => {
const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false)
app.on('message', (m) => console.log(m))
app.on('error', (err) => console.error('[error]', err))
attachShutdown(app)
await app.ready()
try {
const result = await app.joinWorld({
invite: cmd.args.invite,
localPort: cmd.flags.port ? Number(cmd.flags.port) : 0
})
const port = result.tunnel.localPort
console.log('')
console.log(`${appName} joined remote world`)
console.log(` name: ${result.invite.name || '(unnamed)'}`)
console.log(` version: ${result.invite.mcVersion || 'unknown'}`)
console.log(` local: 127.0.0.1:${port}`)
console.log('')
console.log('Connect Java Edition to:')
console.log(` 127.0.0.1:${port}`)
console.log('')
console.log('Press Ctrl+C to stop.')
await new Promise(() => {})
} catch (err) {
console.error('[join failed]', err)
await app.close()
exit(1)
}
}
)
const statusCmd = command(
'status',
summary('Show storage path and worlds'),
@@ -194,18 +287,16 @@ const root = command(
createCmd,
listCmd,
startCmd,
hostCmd,
joinCmd,
statusCmd
)
const program = root.parse(rawArgv)
root.parse(rawArgv)
if (root.flags.version) {
console.log(`${appName} v${pkg.version}`)
exit(0)
}
if (program === null && !rawArgv.length) {
exit(0)
}
// silence unused in some linters
void __dirname
void __filename
+4
View File
@@ -462,5 +462,9 @@
"node:zlib": {
"bare": "bare-zlib",
"default": "zlib"
},
"esbuild-import-glob(path:.,skipFiles:index.js,external.js)": {
"bare": "file:///Users/raven/dev/flying-jib/build/stubs/esbuild-import-glob.cjs",
"default": "file:///Users/raven/dev/flying-jib/build/stubs/esbuild-import-glob.cjs"
}
}
+3
View File
@@ -0,0 +1,3 @@
'use strict'
// flying-squid browser/esbuild path — unused on Bare/Node desktop
module.exports = {}
+4 -3
View File
@@ -54,9 +54,10 @@ Optional packaged forms (`.AppImage`, `.pkg`, `.msix`) via bare-build package mo
## Flying Squid in the bundle
- Include Prismarine graph with **`bare-node-runtime`** `imports` map
- Pack with `bare-pack` (or bare-build graph traverse) so all modules resolve offline
- Natives: only target host prebuilds (peardock-style prune of multi-arch bloat)
- Include Prismarine graph with **`bare-node-runtime`** + project **`build/squid-imports.json`** (generated postinstall)
- **Note:** plain `bare-build` currently fails resolving flying-squids browser-only `esbuild-import-glob(...)` specifier. Prefer peardock-style **`bare-pack` with imports** then embed into bare-runtime (see peardock `scripts/bare-standalone.cjs`). Stub at `build/stubs/esbuild-import-glob.cjs` is prepared for that path.
- Natives: only target host prebuilds (prune multi-arch bloat)
## Pear deployment layers
+1 -1
View File
@@ -1,7 +1,7 @@
# P2P Protocol
**Last updated:** 2026-07-30
**Status:** Spec for implementation (Phases 24)
**Status:** Phase 2 tunnel + invites **implemented** (host/join CLI); mesh channels later
**ADRs:** 0006, 0007, 0008, 0009
## Design rules
+10 -9
View File
@@ -52,18 +52,19 @@ npm install
# postinstall: patches engines for Bare + generates build/squid-imports.json
# Preferred (uses packaged bare-runtime ≥1.29)
npm start -- create demo
npm start -- list
npm start -- start demo --port 25565
# equivalent:
./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/fj-a create demo
./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/fj-a start demo --port 25565
BARE=./node_modules/bare-runtime/bin/bare
# Custom storage (simulate two peers on one machine)
./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/flying-jib-a start demo
$BARE bin.mjs --storage /tmp/fj-a create demo
$BARE bin.mjs --storage /tmp/fj-a list
$BARE bin.mjs --storage /tmp/fj-a start demo --port 25565 # local only
# Private world share (Phase 2)
$BARE bin.mjs --storage /tmp/fj-host host demo --port 25565
# copy the printed fj1.… invite, then on another machine/storage:
$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25565
```
Connect Java Edition to `127.0.0.1:25565` (or the port shown).
Connect Java Edition to `127.0.0.1:<port>` shown by the CLI (host or guest).
**Notes:**
+96
View File
@@ -0,0 +1,96 @@
'use strict'
/**
* fj1. invite mint/parse (ADR-0009).
* Payload is JSON → UTF-8 → z32 for debuggability (Phase 2).
*/
const z32 = require('z32')
const b4a = require('b4a')
const PREFIX = 'fj1.'
const CURRENT_VERSION = 1
/**
* @param {object} payload
* @returns {string}
*/
function encodeInvite(payload) {
const body = {
v: payload.v != null ? payload.v : CURRENT_VERSION,
type: payload.type || 'private-world',
worldKey: payload.worldKey,
cap: payload.cap,
name: payload.name || null,
mcVersion: payload.mcVersion || null,
portHint: payload.portHint != null ? payload.portHint : null,
expires: payload.expires != null ? payload.expires : null
}
if (!body.worldKey || !body.cap) {
throw new Error('invite requires worldKey and cap')
}
const json = JSON.stringify(body)
return PREFIX + z32.encode(b4a.from(json))
}
/**
* @param {string} str
* @returns {object}
*/
function decodeInvite(str) {
const s = String(str || '').trim()
if (!s.startsWith(PREFIX)) {
throw new Error('Invalid invite: expected fj1. prefix')
}
let json
try {
json = b4a.toString(z32.decode(s.slice(PREFIX.length)))
} catch (err) {
throw new Error('Invalid invite: bad z32 payload')
}
let body
try {
body = JSON.parse(json)
} catch {
throw new Error('Invalid invite: bad JSON payload')
}
if (body.v !== CURRENT_VERSION) {
throw new Error(`Unsupported invite version: ${body.v}`)
}
if (body.type !== 'private-world') {
throw new Error(`Unsupported invite type: ${body.type}`)
}
if (!body.worldKey || !body.cap) {
throw new Error('Invalid invite: missing worldKey or cap')
}
if (body.expires != null && Number(body.expires) > 0 && Date.now() > Number(body.expires)) {
throw new Error('Invite expired')
}
return body
}
/**
* Decode z32 public key / cap to Buffer.
* @param {string} z
* @returns {Buffer}
*/
function decodeKey(z) {
return b4a.from(z32.decode(String(z)))
}
/**
* @param {Buffer|Uint8Array} buf
* @returns {string}
*/
function encodeKey(buf) {
return z32.encode(b4a.from(buf))
}
module.exports = {
PREFIX,
CURRENT_VERSION,
encodeInvite,
decodeInvite,
decodeKey,
encodeKey
}
+65
View File
@@ -0,0 +1,65 @@
'use strict'
/**
* Persist per-world HyperDHT tunnel seed + capability under the world folder.
*/
const fs = require('fs')
const path = require('path')
const crypto = require('hypercore-crypto')
const b4a = require('b4a')
const { worldDir } = require('./paths')
const { encodeKey } = require('./invite')
function tunnelPath(storageDir, worldId) {
return path.join(worldDir(storageDir, worldId), 'tunnel.json')
}
/**
* Load or create tunnel secrets for a world.
* @returns {{ seed: Buffer, cap: Buffer, publicKeyZ32: string, capZ32: string }}
*/
function loadOrCreateTunnelKeys(storageDir, worldId) {
const file = tunnelPath(storageDir, worldId)
if (fs.existsSync(file)) {
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
const seed = b4a.from(raw.seed, 'hex')
const cap = b4a.from(raw.cap, 'hex')
const keyPair = require('hyperdht').keyPair(seed)
return {
seed,
cap,
publicKeyZ32: encodeKey(keyPair.publicKey),
capZ32: encodeKey(cap),
publicKey: keyPair.publicKey
}
}
const seed = crypto.randomBytes(32)
const cap = crypto.randomBytes(32)
const keyPair = require('hyperdht').keyPair(seed)
const data = {
seed: b4a.toString(seed, 'hex'),
cap: b4a.toString(cap, 'hex'),
publicKey: encodeKey(keyPair.publicKey),
createdAt: Date.now()
}
fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 })
try {
fs.chmodSync(file, 0o600)
} catch {
// windows
}
return {
seed,
cap,
publicKeyZ32: data.publicKey,
capZ32: encodeKey(cap),
publicKey: keyPair.publicKey
}
}
module.exports = {
tunnelPath,
loadOrCreateTunnelKeys
}
+289
View File
@@ -0,0 +1,289 @@
'use strict'
/**
* HyperDHT TCP byte-pipe tunnel (ADR-0006).
* Host: DHT listen → pipe each connection to 127.0.0.1:squidPort
* Guest: local 127.0.0.1 accept → DHT connect(worldKey) → pipe
*
* Shared `cap` (32 bytes): first 36 bytes of the guest→host direction are
* FJ1C || cap; host verifies then forwards remaining MC bytes.
*/
const net = require('net')
const { Transform } = require('stream')
const ReadyResource = require('ready-resource')
const DHT = require('hyperdht')
const b4a = require('b4a')
const crypto = require('hypercore-crypto')
const { forceLoopbackHost } = require('./bind-guard')
const CAP_MAGIC = b4a.from('FJ1C') // 4 + 32 = 36 byte header
function bindPipes(a, b) {
a.pipe(b)
b.pipe(a)
const destroy = (err) => {
try {
a.destroy(err)
} catch {
/* ignore */
}
try {
b.destroy(err)
} catch {
/* ignore */
}
}
a.on('error', destroy)
b.on('error', destroy)
}
/** Host-side: strip and verify capability header, then pass through. */
function createCapVerifyTransform(expectedCap) {
if (!expectedCap || expectedCap.byteLength !== 32) {
return new Transform({
transform(chunk, enc, cb) {
this.push(chunk)
cb()
}
})
}
let buf = b4a.alloc(0)
let verified = false
return new Transform({
transform(chunk, enc, cb) {
if (verified) {
this.push(chunk)
return cb()
}
buf = b4a.concat([buf, chunk])
if (buf.byteLength < 36) return cb()
const magic = buf.subarray(0, 4)
const cap = buf.subarray(4, 36)
if (!b4a.equals(magic, CAP_MAGIC) || !b4a.equals(cap, expectedCap)) {
return cb(new Error('capability rejected'))
}
verified = true
const rest = buf.subarray(36)
buf = b4a.alloc(0)
if (rest.byteLength) this.push(rest)
cb()
}
})
}
/** Guest-side: prepend cap header once, then pass through. */
function createCapSendTransform(cap) {
if (!cap || cap.byteLength !== 32) {
return new Transform({
transform(chunk, enc, cb) {
this.push(chunk)
cb()
}
})
}
let sent = false
return new Transform({
transform(chunk, enc, cb) {
if (!sent) {
sent = true
this.push(b4a.concat([CAP_MAGIC, cap, chunk]))
return cb()
}
this.push(chunk)
cb()
}
})
}
class WorldTunnelHost extends ReadyResource {
/**
* @param {object} opts
* @param {number} opts.localPort Squid port on 127.0.0.1
* @param {Buffer} [opts.seed] 32-byte seed for DHT keyPair
* @param {Buffer} [opts.cap] 32-byte shared secret
* @param {number} [opts.maxConnections=32]
*/
constructor(opts) {
super()
this.localPort = Number(opts.localPort)
this.localHost = forceLoopbackHost('127.0.0.1')
this.seed = opts.seed || crypto.randomBytes(32)
this.cap = opts.cap || crypto.randomBytes(32)
this.maxConnections = opts.maxConnections || 32
this.dht = null
this.server = null
this.keyPair = null
this._connections = 0
}
get publicKey() {
return this.keyPair && this.keyPair.publicKey
}
get status() {
return {
role: 'host',
running: this.opened && !this.closed,
localHost: this.localHost,
localPort: this.localPort,
publicKey: this.publicKey,
connections: this._connections
}
}
async _open() {
this.dht = new DHT()
this.keyPair = DHT.keyPair(this.seed)
this.server = this.dht.createServer({
firewall: () => false
})
this.server.on('connection', (noiseSocket) => {
this._onRemote(noiseSocket)
})
await this.server.listen(this.keyPair)
this.emit('listening', this.publicKey)
}
_onRemote(noiseSocket) {
if (this._connections >= this.maxConnections) {
noiseSocket.destroy()
return
}
this._connections++
noiseSocket.once('close', () => {
this._connections = Math.max(0, this._connections - 1)
})
const gate = createCapVerifyTransform(this.cap)
const local = net.connect({ host: this.localHost, port: this.localPort })
gate.on('error', (err) => {
noiseSocket.destroy(err)
local.destroy(err)
})
local.on('error', () => noiseSocket.destroy())
noiseSocket.on('error', () => local.destroy())
// remote → gate → local ; local → remote
noiseSocket.pipe(gate).pipe(local)
local.pipe(noiseSocket)
this.emit('connection')
}
async _close() {
if (this.server) {
try {
await this.server.close()
} catch {
/* ignore */
}
this.server = null
}
if (this.dht) {
try {
await this.dht.destroy()
} catch {
/* ignore */
}
this.dht = null
}
}
}
class WorldTunnelClient extends ReadyResource {
/**
* @param {object} opts
* @param {Buffer} opts.publicKey host DHT public key
* @param {Buffer} [opts.cap]
* @param {number} [opts.localPort=0] 0 = ephemeral
*/
constructor(opts) {
super()
if (!opts || !opts.publicKey) throw new Error('WorldTunnelClient requires publicKey')
this.publicKey = b4a.from(opts.publicKey)
this.cap = opts.cap ? b4a.from(opts.cap) : null
this.localPort = opts.localPort != null ? Number(opts.localPort) : 0
this.localHost = forceLoopbackHost('127.0.0.1')
this.dht = null
this.server = null
this._boundPort = null
}
get status() {
return {
role: 'client',
running: this.opened && !this.closed,
localHost: this.localHost,
localPort: this._boundPort,
remotePublicKey: this.publicKey
}
}
async _open() {
this.dht = new DHT()
this.server = net.createServer((localSocket) => {
this._onLocal(localSocket)
})
await new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(this.localPort, this.localHost, () => {
const addr = this.server.address()
this._boundPort = addr && addr.port
resolve()
})
})
this.emit('listening', this._boundPort)
}
_onLocal(localSocket) {
const remote = this.dht.connect(this.publicKey)
const gate = createCapSendTransform(this.cap)
const open = () => {
localSocket.on('error', () => remote.destroy())
remote.on('error', () => localSocket.destroy())
gate.on('error', () => {
localSocket.destroy()
remote.destroy()
})
// local → gate (prepends cap once) → remote ; remote → local
localSocket.pipe(gate).pipe(remote)
remote.pipe(localSocket)
}
if (remote.opened) open()
else remote.once('open', open)
}
async _close() {
if (this.server) {
await new Promise((resolve) => {
this.server.close(() => resolve())
setTimeout(resolve, 2000)
})
this.server = null
}
if (this.dht) {
try {
await this.dht.destroy()
} catch {
/* ignore */
}
this.dht = null
}
this._boundPort = null
}
}
module.exports = {
WorldTunnelHost,
WorldTunnelClient,
CAP_MAGIC,
createCapVerifyTransform,
createCapSendTransform
}
+5
View File
@@ -11,6 +11,11 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
### Added
- Phase 2 private world sharing (core):
- CLI: `host` (share), `join` (client tunnel)
- HyperDHT world tunnel with capability header
- `fj1.` invite encode/decode
- Per-world tunnel key persistence
- Phase 1 Bare application scaffold:
- CLI: `create`, `list`, `start`, `status`
- `lib/squid-manager.js` — embedded Flying Squid, forced `127.0.0.1`
+29 -40
View File
@@ -1,14 +1,14 @@
# Flying Jib — Current Status
**Snapshot date:** 2026-07-30
**Phase:** 1 **In Progress** (local Bare Squid works)
**Product:** Flying Jib — decentralized P2P Minecraft mesh on **Bare/Pear**
**Phase:** 1 largely done · **Phase 2 core working** (host/join tunnel)
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
---
## One-line summary
**Phase 1 core works:** Bare CLI starts Flying Squid on **`127.0.0.1` only** (bind-guard + tests). Next: world UX polish, `bare-build --standalone` package, then Phase 2 tunnels.
**Bare CLI hosts Flying Squid on loopback and can share private worlds via HyperDHT + `fj1.` invites.** Guests join with a local tunnel port; Java Edition always uses localhost.
---
@@ -16,14 +16,13 @@
| Area | Status |
|------|--------|
| Architecture decisions (ADRs 00010013) | Documented & accepted |
| Bare app scaffold (`bin.mjs`, `app.js`, worker stub) | Working |
| CLI: `create` / `list` / `start` / `status` | Working |
| Flying Squid on **Bare** (`bare-runtime` + bare-node-runtime + shims) | **Verified** listen 127.0.0.1 |
| Flying Squid on Node (dev aid only) | Verified |
| `fj-bind-guard` plugin | Logs OK loopback |
| Unit tests (bind-guard, worlds) | Passing |
| Docs trees + agent rules | Present |
| Bare app CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working |
| Flying Squid on Bare + Node (dev) | Loopback listen verified |
| `fj-bind-guard` | OK |
| Unit tests (bind-guard, worlds, invite, tunnel) | **10/10 pass** |
| HyperDHT world tunnel host↔client | Integration test + CLI smoke |
| `fj1.` invite mint/parse | Working |
| Graceful shutdown (SIGINT re-entrancy guard) | Improved |
---
@@ -31,42 +30,32 @@
| Area | Status |
|------|--------|
| `bare-build --standalone` release artifact | Not run yet |
| HyperDHT world tunnel / invites | Phase 2 |
| Mesh registry / Autobase | Phase 4 |
| GUI | Not started (CLI first) |
| Pear OTA worker with real upgrade key | Stub only |
| Full Java client playtest matrix | Manual TBD |
| `bare-build --standalone` release artifact | In progress / unproven |
| Presence / chat Protomux channels | Phase 3 |
| Mesh enrollment / borders | Phase 4 |
| GUI | Not started |
| Production Pear OTA key | Placeholder |
| Full Java multiplayer playtest on two machines | Manual |
---
## Active risks (top)
## How to try (two terminals)
1. Bare/Prismarine interop shims (events, readline, engines) need maintenance (Q15 ongoing).
2. Gameplay parity vs vanilla (Q1).
3. P2P latency for tunnelled multiplayer (Q2).
4. Packaging size / native prebuilds for bare-build (Q7).
```sh
BARE=./node_modules/bare-runtime/bin/bare
$BARE bin.mjs --storage /tmp/fj-host create demo
$BARE bin.mjs --storage /tmp/fj-host host demo --port 25565
# copy fj1.… invite
See [OPEN_QUESTIONS.md](./OPEN_QUESTIONS.md).
$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25566
# Java → 127.0.0.1:25565 (host) or :25566 (guest)
```
---
## Next actions
1. Manual Java Edition join to local Bare Squid.
2. `npm run make` / bare-build standalone smoke (no Node on PATH).
3. Phase 2: HyperDHT world tunnel + `fj1.` invites.
4. Harden postinstall shims and document in SETUP.
---
## How to navigate
| Need | Go to |
|------|--------|
| Phases & acceptance | [ROADMAP.md](./ROADMAP.md) |
| System diagram | [ARCHITECTURE_OVERVIEW.md](./ARCHITECTURE_OVERVIEW.md) |
| Runtime decision | [../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md](../agent/ADRs/0013-bare-pear-only-runtime-and-distribution.md) |
| Deep tech | [../developer_docs/](../developer_docs/) |
| End users | [../user_docs/](../user_docs/) |
| Agent rules | [../agent/](../agent/) |
1. Manual Java Edition multiplayer through tunnel
2. Finish bare-build standalone CI path
3. Phase 3: presence + chat over Protomux
4. Hardening: tunnel connection limits UX, invite rotate
+1 -1
View File
@@ -15,7 +15,7 @@
| Q4 | Autobase writer admission: who may enroll regions into a mesh? | High | Capability-gated enrollment; mesh invite ≠ region announce | Open |
| Q5 | Coordinate / seed continuity across independently generated regions | Medium | Shared seed + offset policy ADR in Phase 4 | Open |
| Q6 | Inventory/XP integrity during migration (races, duplication) | High | Two-phase migrate commit; abort + kick if incomplete | Open |
| Q7 | Packaging Squid + natives for bare-build standalone (prebuilds, pack size) | Medium | peardock bare-standalone + bare-pack; prune multi-arch natives | Open |
| Q7 | Packaging Squid for bare-build standalone | High | bare-build fails on flying-squid esbuild-import-glob; need peardock-style `bare-pack` + imports map (build/squid-imports.json) | Open |
| Q8 | Accidental AGPL dependency (e.g. holesail) | Medium | Dependency policy + CI license check (ADR-0012) | Open |
| Q9 | Public mesh spam / content liability | High (policy) | Private-by-default; public mesh experimental only | Open |
| Q10 | minecraft-protocol default listen on all interfaces | Critical | Wrapper forces `127.0.0.1`; bind-guard unit test in Phase 1 | Open |
+19
View File
@@ -5,6 +5,25 @@
---
## 2026-07-30 — Phase 2 core: HyperDHT tunnel + fj1. invites
### Wins
- `lib/world-tunnel.js` — host/client HyperDHT TCP pipes with capability header (FJ1C||cap)
- `lib/invite.js``fj1.` z32 JSON invites (ADR-0009)
- `lib/tunnel-keys.js` — per-world tunnel seed/cap on disk (mode 0600)
- CLI: **`host`** (Squid + share) and **`join`** (client tunnel)
- Tests: invite codec + tunnel integration (**10/10**)
- Bare CLI smoke: host prints invite; guest binds 127.0.0.1; both listen
### Next
- bare-build standalone
- Java Edition multiplayer playtest
- Phase 3 presence/chat
---
## 2026-07-30 — Phase 1: Bare CLI + Flying Squid on loopback
### Wins
+6 -4
View File
@@ -88,7 +88,7 @@
| Field | Value |
|-------|-------|
| **Status** | Not Started |
| **Status** | In Progress |
| **Owner** | TBD |
| **Target** | TBD |
| **ADRs** | 0006, 0009 |
@@ -102,9 +102,11 @@
### Acceptance
- [ ] Host generates invite; guest joins over P2P
- [ ] Two independent storage instances can play together
- [ ] Secrets never logged in plaintext
- [x] Host generates invite (`host` command); guest joins (`join`) over P2P tunnel
- [x] Two independent storage instances CLI smoke (Bare)
- [x] Integration test: HyperDHT pipe with cap header
- [ ] Secrets never logged in plaintext (review logs; avoid printing full invite in progress logs)
- [ ] Two-machine Java Edition playtest
### Documentation required to complete
+5 -1
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.7",
"bare-assert": "^1.1.0",
"bare-buffer": "^3.3.1",
"bare-console": "^6.0.1",
@@ -40,11 +41,14 @@
"flying-squid": "^1.12.0",
"framed-stream": "^1.0.1",
"graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0",
"hyperswarm": "^4.17.0",
"paparam": "^1.10.1",
"pear-runtime": "^1.2.0",
"ready-resource": "^1.2.0",
"which-runtime": "^1.4.0"
"which-runtime": "^1.4.0",
"z32": "^1.1.0"
},
"bin": {
"flying-jib": "bin.mjs"
+5 -1
View File
@@ -169,14 +169,18 @@
"bare-zlib": "^1.3.0",
"corestore": "^7.9.2",
"events": "^3.3.0",
"b4a": "^1.6.7",
"flying-squid": "^1.12.0",
"framed-stream": "^1.0.1",
"graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0",
"hyperswarm": "^4.17.0",
"paparam": "^1.10.1",
"pear-runtime": "^1.2.0",
"ready-resource": "^1.2.0",
"which-runtime": "^1.4.0"
"which-runtime": "^1.4.0",
"z32": "^1.1.0"
},
"devDependencies": {
"bare-build": "^1.0.2",
+7
View File
@@ -21,6 +21,13 @@ map['node:events'] = { bare: eventsShim, default: 'events' }
map.readline = { bare: readlineShim, default: 'readline' }
map['node:readline'] = { bare: readlineShim, default: 'readline' }
// flying-squid plugins/index.js browser branch — not used on desktop Bare
const esbuildStub = pathToFileURL(
path.join(root, 'build/stubs/esbuild-import-glob.cjs')
).href
const esbuildSpec = 'esbuild-import-glob(path:.,skipFiles:index.js,external.js)'
map[esbuildSpec] = { bare: esbuildStub, default: esbuildStub }
const outDir = path.join(root, 'build')
fs.mkdirSync(outDir, { recursive: true })
const out = path.join(outDir, 'squid-imports.json')
+33
View File
@@ -0,0 +1,33 @@
'use strict'
const test = require('brittle')
const { encodeInvite, decodeInvite, PREFIX } = require('../lib/invite')
test('round-trip invite', (t) => {
const inv = encodeInvite({
worldKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
cap: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
name: 'Demo',
mcVersion: '1.21.1',
portHint: 25565
})
t.ok(inv.startsWith(PREFIX))
const body = decodeInvite(inv)
t.is(body.name, 'Demo')
t.is(body.mcVersion, '1.21.1')
t.is(body.type, 'private-world')
t.is(body.v, 1)
})
test('rejects bad prefix', (t) => {
t.exception(() => decodeInvite('pd1.nope'))
})
test('rejects expired', (t) => {
const inv = encodeInvite({
worldKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
cap: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
expires: Date.now() - 1000
})
t.exception(() => decodeInvite(inv))
})
+58
View File
@@ -0,0 +1,58 @@
'use strict'
/**
* Local HyperDHT tunnel smoke: host pipes to a tiny TCP echo server on loopback;
* client binds another loopback port and we send a byte through.
*/
const test = require('brittle')
const net = require('net')
const { WorldTunnelHost, WorldTunnelClient } = require('../lib/world-tunnel')
const crypto = require('hypercore-crypto')
test('host/client tunnel pipes TCP over HyperDHT', { timeout: 60000 }, async (t) => {
// Echo server as stand-in for Squid
const echo = net.createServer((c) => {
c.pipe(c)
})
await new Promise((resolve) => echo.listen(0, '127.0.0.1', resolve))
const echoPort = echo.address().port
const seed = crypto.randomBytes(32)
const cap = crypto.randomBytes(32)
const host = new WorldTunnelHost({ localPort: echoPort, seed, cap })
await host.ready()
t.ok(host.publicKey)
const client = new WorldTunnelClient({
publicKey: host.publicKey,
cap,
localPort: 0
})
await client.ready()
const localPort = client.status.localPort
t.ok(localPort > 0)
const reply = await new Promise((resolve, reject) => {
const s = net.connect({ host: '127.0.0.1', port: localPort }, () => {
s.write('ping-fj')
})
const timer = setTimeout(() => {
s.destroy()
reject(new Error('tunnel timeout'))
}, 20000)
s.on('data', (d) => {
clearTimeout(timer)
resolve(d.toString())
s.end()
})
s.on('error', reject)
})
t.is(reply, 'ping-fj')
await client.close()
await host.close()
await new Promise((resolve) => echo.close(resolve))
})
+7 -5
View File
@@ -28,14 +28,16 @@ Until then, developers build from source — see [../developer_docs/SETUP.md](..
5. Open Minecraft Java Edition → Multiplayer → Add server → address `127.0.0.1` or `localhost` and that port.
6. Join and play solo.
## Invite a friend (**Planned**)
## Invite a friend (CLI)
1. With the world running, open **Invite** and copy the capability string (or QR).
2. Send it through a private channel (chat, password manager, etc.).
3. Friend opens Flying Jib **Join** → pastes invite.
4. Friends app opens a local port; they connect Minecraft to **their** localhost.
1. Host: `flying-jib host <world>` — copy the `fj1.…` invite (secret).
2. Send it through a private channel.
3. Friend: `flying-jib join 'fj1.…'`
4. Friend connects Java Edition to **their** localhost port shown by the app.
5. You both appear in the same world hosted on the inviters machine.
GUI invite/QR is still **Planned**.
## Safety basics
- Anyone with your invite can try to join — treat it like a password.
+1 -1
View File
@@ -1,6 +1,6 @@
# Invites and Capabilities
**Status:** Spec ready; UI Planned (Phase 2)
**Status:** CLI mint/parse working (`host` / `join`). UI Planned.
## What is an invite?
+20 -4
View File
@@ -1,6 +1,6 @@
# Private Worlds
**Status:** Planned (Phase 2)
**Status:** Core available in CLI (Phase 2 in progress). GUI later.
## What is a private world?
@@ -10,10 +10,26 @@ A world that lives on **one host peers** computer. Only people who have a val
The person who created/started the world runs the actual Minecraft simulation (Flying Squid inside Flying Jib). If the host closes the app or goes offline, guests disconnect. There is no automatic “failover server.”
## How guests connect
## How guests connect (CLI)
1. Guest pastes the invite into Flying Jib.
2. Flying Jib opens an encrypted peer-to-peer tunnel to the host.
**Host:**
```sh
flying-jib create myworld
flying-jib host myworld --port 25565
# Copy the fj1.… invite (keep it secret)
# Java Edition → 127.0.0.1:25565
```
**Guest:**
```sh
flying-jib join 'fj1.…' --port 25565
# Java Edition → 127.0.0.1:25565 (guests local port)
```
1. Guest pastes the invite into Flying Jib (`join`).
2. Flying Jib opens an encrypted HyperDHT tunnel to the host.
3. On the guest machine, Minecraft still connects to `localhost`.
4. Guest never has to know the hosts IP address.