P1
This commit is contained in:
@@ -51,13 +51,18 @@ Java Edition always connects to **localhost**. The Bare app owns Squid’s 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
|
||||
|
||||
|
||||
@@ -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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
'use strict'
|
||||
// flying-squid browser/esbuild path — unused on Bare/Node desktop
|
||||
module.exports = {}
|
||||
@@ -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-squid’s 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,7 +1,7 @@
|
||||
# P2P Protocol
|
||||
|
||||
**Last updated:** 2026-07-30
|
||||
**Status:** Spec for implementation (Phases 2–4)
|
||||
**Status:** Phase 2 tunnel + invites **implemented** (host/join CLI); mesh channels later
|
||||
**ADRs:** 0006, 0007, 0008, 0009
|
||||
|
||||
## Design rules
|
||||
|
||||
+10
-9
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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 0001–0013) | 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+5
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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. Friend’s 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 inviter’s machine.
|
||||
|
||||
GUI invite/QR is still **Planned**.
|
||||
|
||||
## Safety basics
|
||||
|
||||
- Anyone with your invite can try to join — treat it like a password.
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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 peer’s** 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 (guest’s 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 host’s IP address.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user