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 ## Development
```sh ```sh
# Phase 1+ (Bare) npm install # dev machine only; postinstall patches Bare engines + imports
npm install # dev machine only BARE=./node_modules/bare-runtime/bin/bare
npm start # bare bin.mjs --no-updates
# Minecraft Java → 127.0.0.1:25565 $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 ## License
+105 -8
View File
@@ -8,11 +8,14 @@ const { SquidManager } = require('./lib/squid-manager')
const { createWorld, listWorlds, resolveWorldPaths } = require('./lib/worlds') const { createWorld, listWorlds, resolveWorldPaths } = require('./lib/worlds')
const { worldsRoot } = require('./lib/paths') const { worldsRoot } = require('./lib/paths')
const { ensureDir } = require('./lib/worlds') 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. * Flying Jib Bare application shell.
* - Optional pear-runtime worker for OTA (hello-pear-bare pattern) * - 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 { module.exports = class App extends ReadyResource {
constructor({ dir, appPath, updates, version, upgrade, name }) { constructor({ dir, appPath, updates, version, upgrade, name }) {
@@ -26,14 +29,16 @@ module.exports = class App extends ReadyResource {
this.IPC = null this.IPC = null
this.pipe = null this.pipe = null
this.squid = null this.squid = null
this.tunnelHost = null
this.tunnelClient = null
this.activeWorld = null this.activeWorld = null
this._shuttingDown = false
} }
_open() { _open() {
ensureDir(worldsRoot(this.dir)) ensureDir(worldsRoot(this.dir))
ensureDir(path.join(this.dir, 'corestore')) ensureDir(path.join(this.dir, 'corestore'))
// Pear OTA worker (optional — disabled when --no-updates and no upgrade key)
const enableWorker = const enableWorker =
this.updates !== false && this.upgrade && !String(this.upgrade).includes('<YOUR_KEY') this.updates !== false && this.upgrade && !String(this.upgrade).includes('<YOUR_KEY')
@@ -58,10 +63,19 @@ module.exports = class App extends ReadyResource {
} }
async _close() { 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) { if (this.squid) {
await this.squid.close().catch(() => {}) await this.squid.close().catch(() => {})
this.squid = null this.squid = null
} }
this.activeWorld = null
const pipe = this.pipe const pipe = this.pipe
const IPC = this.IPC const IPC = this.IPC
this.pipe = null this.pipe = null
@@ -117,7 +131,7 @@ module.exports = class App extends ReadyResource {
port, port,
version, version,
motd: meta.motd || `Flying Jib — ${meta.name}`, motd: meta.motd || `Flying Jib — ${meta.name}`,
logging: true logging: false
}) })
this.squid.on('error', (err) => this.emit('error', err)) this.squid.on('error', (err) => this.emit('error', err))
@@ -130,24 +144,107 @@ module.exports = class App extends ReadyResource {
return this.squid.status 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() { async stopWorld() {
if (!this.squid) return false let stopped = false
await this.squid.close() if (this.tunnelClient) {
this.squid = null 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 this.activeWorld = null
return true return stopped
} }
getStatus() { getStatus() {
return { return {
storage: this.dir, storage: this.dir,
world: this.activeWorld, 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) { async exit(code = 0) {
if (this._shuttingDown) return
this._shuttingDown = true
if (typeof Bare !== 'undefined') Bare.exitCode = code if (typeof Bare !== 'undefined') Bare.exitCode = code
try {
await this.stopWorld()
} catch {
// ignore
}
await this.close() await this.close()
} }
} }
+126 -35
View File
@@ -2,9 +2,11 @@
* Flying Jib CLI — Bare entry (ADR-0013). * Flying Jib CLI — Bare entry (ADR-0013).
* *
* Usage: * Usage:
* bare bin.mjs create <name> [--version 1.21.1] * bare bin.mjs create <name>
* bare bin.mjs list * 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 * bare bin.mjs status
*/ */
@@ -24,7 +26,6 @@ const appName = pkg.productName || pkg.name
const isBare = typeof Bare !== 'undefined' const isBare = typeof Bare !== 'undefined'
const argv0 = isBare ? Bare.argv[0] : process.argv[0] const argv0 = isBare ? Bare.argv[0] : process.argv[0]
const base = path.basename(argv0) 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 isDev = base === 'bare' || base === 'node' || /bare-runtime/.test(argv0)
const rawArgv = isBare ? Bare.argv.slice(isDev ? 2 : 1) : process.argv.slice(2) 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( const createCmd = command(
'create', 'create',
summary('Create a new local world'), summary('Create a new local world'),
@@ -107,37 +140,16 @@ const listCmd = command(
const startCmd = command( const startCmd = command(
'start', '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'), arg('<name>', 'world name / id'),
flag('--port|-p [port]', 'local Minecraft port (default 25565)'), flag('--port|-p [port]', 'local Minecraft port (default 25565)'),
flag('--version [ver]', 'override Minecraft version'), flag('--version [ver]', 'override Minecraft version'),
flag('--no-updates', 'disable OTA updates for this run'),
async (cmd) => { async (cmd) => {
const storage = resolveStorage(root.flags.storage) const storage = resolveStorage(root.flags.storage)
// paparam: --no-updates → flags.updates === false when present const app = makeApp(storage, false)
const updates = cmd.flags.updates === false || root.flags.updates === false ? false : false
const app = makeApp(storage, updates)
app.on('message', (m) => console.log(m)) app.on('message', (m) => console.log(m))
app.on('error', (err) => console.error('[error]', err)) app.on('error', (err) => console.error('[error]', err))
attachShutdown(app)
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)
})
await app.ready() await app.ready()
try { try {
@@ -147,11 +159,9 @@ const startCmd = command(
version: cmd.flags.version version: cmd.flags.version
}) })
console.log('') 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(` world: ${cmd.args.name}`)
console.log(` bind: ${status.host}:${status.port}`) 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('')
console.log('Connect Java Edition to:') console.log('Connect Java Edition to:')
console.log(` ${status.host}:${status.port}`) 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( const statusCmd = command(
'status', 'status',
summary('Show storage path and worlds'), summary('Show storage path and worlds'),
@@ -194,18 +287,16 @@ const root = command(
createCmd, createCmd,
listCmd, listCmd,
startCmd, startCmd,
hostCmd,
joinCmd,
statusCmd statusCmd
) )
const program = root.parse(rawArgv) root.parse(rawArgv)
if (root.flags.version) { if (root.flags.version) {
console.log(`${appName} v${pkg.version}`) console.log(`${appName} v${pkg.version}`)
exit(0) exit(0)
} }
if (program === null && !rawArgv.length) {
exit(0)
}
// silence unused in some linters
void __dirname void __dirname
void __filename void __filename
+4
View File
@@ -462,5 +462,9 @@
"node:zlib": { "node:zlib": {
"bare": "bare-zlib", "bare": "bare-zlib",
"default": "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 ## Flying Squid in the bundle
- Include Prismarine graph with **`bare-node-runtime`** `imports` map - Include Prismarine graph with **`bare-node-runtime`** + project **`build/squid-imports.json`** (generated postinstall)
- Pack with `bare-pack` (or bare-build graph traverse) so all modules resolve offline - **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 (peardock-style prune of multi-arch bloat) - Natives: only target host prebuilds (prune multi-arch bloat)
## Pear deployment layers ## Pear deployment layers
+1 -1
View File
@@ -1,7 +1,7 @@
# P2P Protocol # P2P Protocol
**Last updated:** 2026-07-30 **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 **ADRs:** 0006, 0007, 0008, 0009
## Design rules ## Design rules
+10 -9
View File
@@ -52,18 +52,19 @@ npm install
# postinstall: patches engines for Bare + generates build/squid-imports.json # postinstall: patches engines for Bare + generates build/squid-imports.json
# Preferred (uses packaged bare-runtime ≥1.29) # Preferred (uses packaged bare-runtime ≥1.29)
npm start -- create demo BARE=./node_modules/bare-runtime/bin/bare
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
# Custom storage (simulate two peers on one machine) $BARE bin.mjs --storage /tmp/fj-a create demo
./node_modules/bare-runtime/bin/bare bin.mjs --storage /tmp/flying-jib-a start 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:** **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 ### 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: - Phase 1 Bare application scaffold:
- CLI: `create`, `list`, `start`, `status` - CLI: `create`, `list`, `start`, `status`
- `lib/squid-manager.js` — embedded Flying Squid, forced `127.0.0.1` - `lib/squid-manager.js` — embedded Flying Squid, forced `127.0.0.1`
+29 -40
View File
@@ -1,14 +1,14 @@
# Flying Jib — Current Status # Flying Jib — Current Status
**Snapshot date:** 2026-07-30 **Snapshot date:** 2026-07-30
**Phase:** 1 **In Progress** (local Bare Squid works) **Phase:** 1 largely done · **Phase 2 core working** (host/join tunnel)
**Product:** Flying Jib — decentralized P2P Minecraft mesh on **Bare/Pear** **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
--- ---
## One-line summary ## 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 | | Area | Status |
|------|--------| |------|--------|
| Architecture decisions (ADRs 00010013) | Documented & accepted | | Bare app CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working |
| Bare app scaffold (`bin.mjs`, `app.js`, worker stub) | Working | | Flying Squid on Bare + Node (dev) | Loopback listen verified |
| CLI: `create` / `list` / `start` / `status` | Working | | `fj-bind-guard` | OK |
| Flying Squid on **Bare** (`bare-runtime` + bare-node-runtime + shims) | **Verified** listen 127.0.0.1 | | Unit tests (bind-guard, worlds, invite, tunnel) | **10/10 pass** |
| Flying Squid on Node (dev aid only) | Verified | | HyperDHT world tunnel host↔client | Integration test + CLI smoke |
| `fj-bind-guard` plugin | Logs OK loopback | | `fj1.` invite mint/parse | Working |
| Unit tests (bind-guard, worlds) | Passing | | Graceful shutdown (SIGINT re-entrancy guard) | Improved |
| Docs trees + agent rules | Present |
--- ---
@@ -31,42 +30,32 @@
| Area | Status | | Area | Status |
|------|--------| |------|--------|
| `bare-build --standalone` release artifact | Not run yet | | `bare-build --standalone` release artifact | In progress / unproven |
| HyperDHT world tunnel / invites | Phase 2 | | Presence / chat Protomux channels | Phase 3 |
| Mesh registry / Autobase | Phase 4 | | Mesh enrollment / borders | Phase 4 |
| GUI | Not started (CLI first) | | GUI | Not started |
| Pear OTA worker with real upgrade key | Stub only | | Production Pear OTA key | Placeholder |
| Full Java client playtest matrix | Manual TBD | | 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). ```sh
2. Gameplay parity vs vanilla (Q1). BARE=./node_modules/bare-runtime/bin/bare
3. P2P latency for tunnelled multiplayer (Q2). $BARE bin.mjs --storage /tmp/fj-host create demo
4. Packaging size / native prebuilds for bare-build (Q7). $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 ## Next actions
1. Manual Java Edition join to local Bare Squid. 1. Manual Java Edition multiplayer through tunnel
2. `npm run make` / bare-build standalone smoke (no Node on PATH). 2. Finish bare-build standalone CI path
3. Phase 2: HyperDHT world tunnel + `fj1.` invites. 3. Phase 3: presence + chat over Protomux
4. Harden postinstall shims and document in SETUP. 4. Hardening: tunnel connection limits UX, invite rotate
---
## 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 -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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ## 2026-07-30 — Phase 1: Bare CLI + Flying Squid on loopback
### Wins ### Wins
+6 -4
View File
@@ -88,7 +88,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| **Status** | Not Started | | **Status** | In Progress |
| **Owner** | TBD | | **Owner** | TBD |
| **Target** | TBD | | **Target** | TBD |
| **ADRs** | 0006, 0009 | | **ADRs** | 0006, 0009 |
@@ -102,9 +102,11 @@
### Acceptance ### Acceptance
- [ ] Host generates invite; guest joins over P2P - [x] Host generates invite (`host` command); guest joins (`join`) over P2P tunnel
- [ ] Two independent storage instances can play together - [x] Two independent storage instances CLI smoke (Bare)
- [ ] Secrets never logged in plaintext - [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 ### Documentation required to complete
+5 -1
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"b4a": "^1.6.7",
"bare-assert": "^1.1.0", "bare-assert": "^1.1.0",
"bare-buffer": "^3.3.1", "bare-buffer": "^3.3.1",
"bare-console": "^6.0.1", "bare-console": "^6.0.1",
@@ -40,11 +41,14 @@
"flying-squid": "^1.12.0", "flying-squid": "^1.12.0",
"framed-stream": "^1.0.1", "framed-stream": "^1.0.1",
"graceful-goodbye": "^1.3.3", "graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0",
"hyperswarm": "^4.17.0", "hyperswarm": "^4.17.0",
"paparam": "^1.10.1", "paparam": "^1.10.1",
"pear-runtime": "^1.2.0", "pear-runtime": "^1.2.0",
"ready-resource": "^1.2.0", "ready-resource": "^1.2.0",
"which-runtime": "^1.4.0" "which-runtime": "^1.4.0",
"z32": "^1.1.0"
}, },
"bin": { "bin": {
"flying-jib": "bin.mjs" "flying-jib": "bin.mjs"
+5 -1
View File
@@ -169,14 +169,18 @@
"bare-zlib": "^1.3.0", "bare-zlib": "^1.3.0",
"corestore": "^7.9.2", "corestore": "^7.9.2",
"events": "^3.3.0", "events": "^3.3.0",
"b4a": "^1.6.7",
"flying-squid": "^1.12.0", "flying-squid": "^1.12.0",
"framed-stream": "^1.0.1", "framed-stream": "^1.0.1",
"graceful-goodbye": "^1.3.3", "graceful-goodbye": "^1.3.3",
"hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0",
"hyperswarm": "^4.17.0", "hyperswarm": "^4.17.0",
"paparam": "^1.10.1", "paparam": "^1.10.1",
"pear-runtime": "^1.2.0", "pear-runtime": "^1.2.0",
"ready-resource": "^1.2.0", "ready-resource": "^1.2.0",
"which-runtime": "^1.4.0" "which-runtime": "^1.4.0",
"z32": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"bare-build": "^1.0.2", "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.readline = { bare: readlineShim, default: 'readline' }
map['node: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') const outDir = path.join(root, 'build')
fs.mkdirSync(outDir, { recursive: true }) fs.mkdirSync(outDir, { recursive: true })
const out = path.join(outDir, 'squid-imports.json') 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. 5. Open Minecraft Java Edition → Multiplayer → Add server → address `127.0.0.1` or `localhost` and that port.
6. Join and play solo. 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). 1. Host: `flying-jib host <world>` — copy the `fj1.…` invite (secret).
2. Send it through a private channel (chat, password manager, etc.). 2. Send it through a private channel.
3. Friend opens Flying Jib **Join** → pastes invite. 3. Friend: `flying-jib join 'fj1.…'`
4. Friends app opens a local port; they connect Minecraft to **their** localhost. 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. 5. You both appear in the same world hosted on the inviters machine.
GUI invite/QR is still **Planned**.
## Safety basics ## Safety basics
- Anyone with your invite can try to join — treat it like a password. - Anyone with your invite can try to join — treat it like a password.
+1 -1
View File
@@ -1,6 +1,6 @@
# Invites and Capabilities # Invites and Capabilities
**Status:** Spec ready; UI Planned (Phase 2) **Status:** CLI mint/parse working (`host` / `join`). UI Planned.
## What is an invite? ## What is an invite?
+20 -4
View File
@@ -1,6 +1,6 @@
# Private Worlds # Private Worlds
**Status:** Planned (Phase 2) **Status:** Core available in CLI (Phase 2 in progress). GUI later.
## What is a private world? ## 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.” 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. **Host:**
2. Flying Jib opens an encrypted peer-to-peer tunnel to the 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`. 3. On the guest machine, Minecraft still connects to `localhost`.
4. Guest never has to know the hosts IP address. 4. Guest never has to know the hosts IP address.