This commit is contained in:
Raven Scott
2026-07-30 23:39:49 -04:00
parent 92cb4b9554
commit 432bca222c
28 changed files with 1086 additions and 94 deletions
+3
View File
@@ -5,6 +5,9 @@ node_modules/
out/ out/
dist/ dist/
deploy/ deploy/
# vendored at postinstall
build/shims/events-polyfill.cjs
build/squid-imports.json
*.AppImage *.AppImage
*.dmg *.dmg
*.snap *.snap
+3
View File
@@ -59,7 +59,10 @@ $BARE bin.mjs host demo --port 25565 # print fj1. invite; local Squid
$BARE bin.mjs join 'fj1.…' --port 25566 # guest tunnel $BARE bin.mjs join 'fj1.…' --port 25566 # guest tunnel
# Minecraft Java → 127.0.0.1:<port shown> # Minecraft Java → 127.0.0.1:<port shown>
# Chat: type lines while host/join is running; /peers
npm test npm test
npm run make:standalone # single Bare executable → out/flying-jib-<host>/
``` ```
See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [living_docs/CURRENT_STATUS.md](./living_docs/CURRENT_STATUS.md). See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [living_docs/CURRENT_STATUS.md](./living_docs/CURRENT_STATUS.md).
+63 -33
View File
@@ -11,14 +11,15 @@ const { ensureDir } = require('./lib/worlds')
const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel') const { WorldTunnelHost, WorldTunnelClient } = require('./lib/world-tunnel')
const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys') const { loadOrCreateTunnelKeys } = require('./lib/tunnel-keys')
const { encodeInvite, decodeInvite, decodeKey } = require('./lib/invite') const { encodeInvite, decodeInvite, decodeKey } = require('./lib/invite')
const { PeerSession } = require('./lib/peer-session')
/** /**
* 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 + HyperDHT tunnels run in this process under Bare (ADR-0013) * - Squid + HyperDHT tunnels + Protomux peer session in this process (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, displayName }) {
super() super()
this.dir = dir this.dir = dir
this.appPath = appPath this.appPath = appPath
@@ -26,11 +27,13 @@ module.exports = class App extends ReadyResource {
this.version = version this.version = version
this.upgrade = upgrade this.upgrade = upgrade
this.name = name this.name = name
this.displayName = displayName || 'player'
this.IPC = null this.IPC = null
this.pipe = null this.pipe = null
this.squid = null this.squid = null
this.tunnelHost = null this.tunnelHost = null
this.tunnelClient = null this.tunnelClient = null
this.peerSession = null
this.activeWorld = null this.activeWorld = null
this._shuttingDown = false this._shuttingDown = false
} }
@@ -63,6 +66,20 @@ module.exports = class App extends ReadyResource {
} }
async _close() { async _close() {
await this._teardownSession()
const pipe = this.pipe
const IPC = this.IPC
this.pipe = null
this.IPC = null
pipe?.destroy()
IPC?.destroy()
}
async _teardownSession() {
if (this.peerSession) {
await this.peerSession.close().catch(() => {})
this.peerSession = null
}
if (this.tunnelClient) { if (this.tunnelClient) {
await this.tunnelClient.close().catch(() => {}) await this.tunnelClient.close().catch(() => {})
this.tunnelClient = null this.tunnelClient = null
@@ -76,12 +93,6 @@ module.exports = class App extends ReadyResource {
this.squid = null this.squid = null
} }
this.activeWorld = null this.activeWorld = null
const pipe = this.pipe
const IPC = this.IPC
this.pipe = null
this.IPC = null
pipe?.destroy()
IPC?.destroy()
} }
_onWorkerMessage(data) { _onWorkerMessage(data) {
@@ -106,6 +117,25 @@ module.exports = class App extends ReadyResource {
if (this.pipe) this.pipe.write(message) if (this.pipe) this.pipe.write(message)
} }
async _startPeerSession(worldPublicKey) {
if (this.peerSession) {
await this.peerSession.close().catch(() => {})
this.peerSession = null
}
this.peerSession = new PeerSession({
worldPublicKey,
displayName: this.displayName
})
this.peerSession.on('chat', (m) => this.emit('chat', m))
this.peerSession.on('presence', (m) => this.emit('presence', m))
this.peerSession.on('peers', (list) => this.emit('peers', list))
this.peerSession.on('peer-join', (m) => this.emit('peer-join', m))
this.peerSession.on('peer-leave', (m) => this.emit('peer-leave', m))
this.peerSession.on('error', (err) => this.emit('error', err))
await this.peerSession.ready()
this.emit('message', `Peer session ready (id ${this.peerSession.peerId})`)
}
listWorlds() { listWorlds() {
return listWorlds(this.dir) return listWorlds(this.dir)
} }
@@ -145,10 +175,11 @@ module.exports = class App extends ReadyResource {
} }
/** /**
* Start Squid + HyperDHT host tunnel; return status + invite string. * Start Squid + HyperDHT host tunnel + peer chat/presence.
* @param {{ world: string, port?: number, version?: string }} opts * @param {{ world: string, port?: number, version?: string, displayName?: string }} opts
*/ */
async hostWorld(opts) { async hostWorld(opts) {
if (opts.displayName) this.displayName = opts.displayName
const squidStatus = await this.startWorld(opts) const squidStatus = await this.startWorld(opts)
const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id) const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id)
@@ -158,11 +189,13 @@ module.exports = class App extends ReadyResource {
cap: keys.cap cap: keys.cap
}) })
this.tunnelHost.on('connection', () => { this.tunnelHost.on('connection', () => {
this.emit('message', 'Remote peer connected via tunnel') this.emit('message', 'Remote peer connected via MC tunnel')
}) })
this.tunnelHost.on('error', (err) => this.emit('error', err)) this.tunnelHost.on('error', (err) => this.emit('error', err))
await this.tunnelHost.ready() await this.tunnelHost.ready()
await this._startPeerSession(keys.publicKey)
const invite = encodeInvite({ const invite = encodeInvite({
type: 'private-world', type: 'private-world',
worldKey: keys.publicKeyZ32, worldKey: keys.publicKeyZ32,
@@ -175,18 +208,20 @@ module.exports = class App extends ReadyResource {
return { return {
squid: squidStatus, squid: squidStatus,
tunnel: this.tunnelHost.status, tunnel: this.tunnelHost.status,
peers: this.peerSession ? this.peerSession.peerList : [],
invite invite
} }
} }
/** /**
* Join a private world via fj1. invite (client tunnel only; no local Squid). * Join a private world via fj1. invite (tunnel + peer session).
* @param {{ invite: string, localPort?: number }} opts * @param {{ invite: string, localPort?: number, displayName?: string }} opts
*/ */
async joinWorld(opts) { async joinWorld(opts) {
if (this.tunnelClient) { if (this.tunnelClient) {
throw new Error('Already joined a remote world; stop first') throw new Error('Already joined a remote world; stop first')
} }
if (opts.displayName) this.displayName = opts.displayName
const inv = decodeInvite(opts.invite) const inv = decodeInvite(opts.invite)
const publicKey = decodeKey(inv.worldKey) const publicKey = decodeKey(inv.worldKey)
const cap = decodeKey(inv.cap) const cap = decodeKey(inv.cap)
@@ -199,40 +234,35 @@ module.exports = class App extends ReadyResource {
this.tunnelClient.on('error', (err) => this.emit('error', err)) this.tunnelClient.on('error', (err) => this.emit('error', err))
await this.tunnelClient.ready() await this.tunnelClient.ready()
await this._startPeerSession(publicKey)
return { return {
invite: inv, invite: inv,
tunnel: this.tunnelClient.status tunnel: this.tunnelClient.status,
peers: this.peerSession ? this.peerSession.peerList : []
} }
} }
sendChat(text) {
if (!this.peerSession) throw new Error('No peer session (host or join first)')
this.peerSession.sendChat(text)
}
async stopWorld() { async stopWorld() {
let stopped = false await this._teardownSession()
if (this.tunnelClient) { return true
await this.tunnelClient.close().catch(() => {})
this.tunnelClient = null
stopped = true
}
if (this.tunnelHost) {
await this.tunnelHost.close().catch(() => {})
this.tunnelHost = null
stopped = true
}
if (this.squid) {
await this.squid.close().catch(() => {})
this.squid = null
stopped = true
}
this.activeWorld = null
return stopped
} }
getStatus() { getStatus() {
return { return {
storage: this.dir, storage: this.dir,
world: this.activeWorld, world: this.activeWorld,
displayName: this.displayName,
squid: this.squid ? this.squid.status : { running: false }, squid: this.squid ? this.squid.status : { running: false },
tunnelHost: this.tunnelHost ? this.tunnelHost.status : null, tunnelHost: this.tunnelHost ? this.tunnelHost.status : null,
tunnelClient: this.tunnelClient ? this.tunnelClient.status : null tunnelClient: this.tunnelClient ? this.tunnelClient.status : null,
peers: this.peerSession ? this.peerSession.peerList : [],
peerId: this.peerSession ? this.peerSession.peerId : null
} }
} }
+77 -13
View File
@@ -14,13 +14,17 @@ import { createRequire } from 'module'
import { command, flag, arg, summary, header, footer } from 'paparam' import { command, flag, arg, summary, header, footer } from 'paparam'
import path from 'path' import path from 'path'
import process from 'process' import process from 'process'
import { fileURLToPath } from 'url'
import pkg from './package.json' with { type: 'json' } import pkg from './package.json' with { type: 'json' }
import App from './app.js' import App from './app.js'
const require = createRequire(import.meta.url) // createRequire needs a file: URL in Node; under bare-pack standalone, import.meta.url
const __filename = fileURLToPath(import.meta.url) // may be app.bundle — fall back to a dummy path for require() of CJS shims.
const __dirname = path.dirname(__filename) let require
try {
require = createRequire(import.meta.url)
} catch {
require = createRequire('file:///flying-jib/bin.mjs')
}
const appName = pkg.productName || pkg.name const appName = pkg.productName || pkg.name
const isBare = typeof Bare !== 'undefined' const isBare = typeof Bare !== 'undefined'
@@ -48,17 +52,72 @@ function exit(code) {
else process.exit(code) else process.exit(code)
} }
function makeApp(storage, updates = false) { function makeApp(storage, updates = false, displayName) {
return new App({ return new App({
dir: storage, dir: storage,
appPath: isDev ? null : argv0, appPath: isDev ? null : argv0,
updates, updates,
version: pkg.version, version: pkg.version,
upgrade: pkg.upgrade, upgrade: pkg.upgrade,
name: appName name: appName,
displayName: displayName || 'player'
}) })
} }
/** Wire chat/presence logging + optional stdin chat lines for host/join */
function attachPeerUi(app) {
app.on('chat', (m) => {
const tag = m.local ? 'you' : m.from
console.log(`[chat] <${tag}> ${m.text}`)
})
app.on('peer-join', (m) => {
console.log(`[peers] joined ${m.peerId.slice(0, 12)}`)
})
app.on('peer-leave', (m) => {
console.log(`[peers] left ${m.peerId.slice(0, 12)}`)
})
app.on('peers', (list) => {
if (list.length) {
console.log(`[peers] online: ${list.map((p) => p.name).join(', ')}`)
}
})
// Type lines to chat (host/join sessions)
if (process.stdin && process.stdin.isTTY && typeof process.stdin.on === 'function') {
try {
process.stdin.setEncoding('utf8')
process.stdin.resume()
let buf = ''
process.stdin.on('data', (chunk) => {
buf += chunk
let i
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).replace(/\r$/, '').trim()
buf = buf.slice(i + 1)
if (!line) continue
if (line === '/peers') {
const st = app.getStatus()
console.log(
'[peers]',
st.peers.length
? st.peers.map((p) => `${p.name}(${p.id.slice(0, 8)})`).join(', ')
: '(none)'
)
continue
}
try {
app.sendChat(line)
} catch (err) {
console.error('[chat]', err.message)
}
}
})
} catch {
// non-interactive
}
}
}
/** Attach signal handlers once; re-entrant safe via app.exit */ /** Attach signal handlers once; re-entrant safe via app.exit */
function attachShutdown(app) { function attachShutdown(app) {
let stopping = false let stopping = false
@@ -182,25 +241,29 @@ const hostCmd = command(
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('--name|-n [display]', 'chat display name'),
async (cmd) => { async (cmd) => {
const storage = resolveStorage(root.flags.storage) const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false) const app = makeApp(storage, false, cmd.flags.name)
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) attachShutdown(app)
attachPeerUi(app)
await app.ready() await app.ready()
try { try {
const result = await app.hostWorld({ const result = await app.hostWorld({
world: cmd.args.name, world: cmd.args.name,
port: cmd.flags.port ? Number(cmd.flags.port) : 25565, port: cmd.flags.port ? Number(cmd.flags.port) : 25565,
version: cmd.flags.version version: cmd.flags.version,
displayName: cmd.flags.name
}) })
console.log('') console.log('')
console.log(`${appName} hosting private world (P2P)`) console.log(`${appName} hosting private world (P2P)`)
console.log(` world: ${cmd.args.name}`) console.log(` world: ${cmd.args.name}`)
console.log(` local: ${result.squid.host}:${result.squid.port}`) console.log(` local: ${result.squid.host}:${result.squid.port}`)
console.log(` tunnel: HyperDHT host`) console.log(` tunnel: HyperDHT host`)
console.log(` chat: type lines + Enter; /peers for list`)
console.log('') console.log('')
console.log('Invite (treat as secret — fj1. capability):') console.log('Invite (treat as secret — fj1. capability):')
console.log(result.invite) console.log(result.invite)
@@ -226,18 +289,21 @@ const joinCmd = command(
summary('Join a private world from an fj1. invite'), summary('Join a private world from an fj1. invite'),
arg('<invite>', 'fj1. invite string'), arg('<invite>', 'fj1. invite string'),
flag('--port|-p [port]', 'local bind port (default: ephemeral)'), flag('--port|-p [port]', 'local bind port (default: ephemeral)'),
flag('--name|-n [display]', 'chat display name'),
async (cmd) => { async (cmd) => {
const storage = resolveStorage(root.flags.storage) const storage = resolveStorage(root.flags.storage)
const app = makeApp(storage, false) const app = makeApp(storage, false, cmd.flags.name)
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) attachShutdown(app)
attachPeerUi(app)
await app.ready() await app.ready()
try { try {
const result = await app.joinWorld({ const result = await app.joinWorld({
invite: cmd.args.invite, invite: cmd.args.invite,
localPort: cmd.flags.port ? Number(cmd.flags.port) : 0 localPort: cmd.flags.port ? Number(cmd.flags.port) : 0,
displayName: cmd.flags.name
}) })
const port = result.tunnel.localPort const port = result.tunnel.localPort
console.log('') console.log('')
@@ -245,6 +311,7 @@ const joinCmd = command(
console.log(` name: ${result.invite.name || '(unnamed)'}`) console.log(` name: ${result.invite.name || '(unnamed)'}`)
console.log(` version: ${result.invite.mcVersion || 'unknown'}`) console.log(` version: ${result.invite.mcVersion || 'unknown'}`)
console.log(` local: 127.0.0.1:${port}`) console.log(` local: 127.0.0.1:${port}`)
console.log(` chat: type lines + Enter; /peers for list`)
console.log('') console.log('')
console.log('Connect Java Edition to:') console.log('Connect Java Edition to:')
console.log(` 127.0.0.1:${port}`) console.log(` 127.0.0.1:${port}`)
@@ -297,6 +364,3 @@ if (root.flags.version) {
console.log(`${appName} v${pkg.version}`) console.log(`${appName} v${pkg.version}`)
exit(0) exit(0)
} }
void __dirname
void __filename
+3 -8
View File
@@ -1,12 +1,7 @@
'use strict' 'use strict'
/** /**
* Flying Squid / readable-stream use `EventEmitter.call(this)` (util.inherits). * Callable EventEmitter for Flying Squid / readable-stream (util.inherits).
* bare-events is an ES class (throws without `new`). * Vendored polyfill is inlined so bare-pack standalone does not need nested requires.
*
* Load the classic npm `events` package by absolute path so we do not recurse
* through package.json / bare-node-runtime "events" import maps.
*/ */
const path = require('path') module.exports = require('./events-polyfill.cjs')
const eventsJs = path.join(__dirname, '..', '..', 'node_modules', 'events', 'events.js')
module.exports = require(eventsJs)
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+1
View File
@@ -0,0 +1 @@
'use strict'; module.exports = {}
+2
View File
@@ -0,0 +1,2 @@
'use strict'
module.exports = {}
+2
View File
@@ -0,0 +1,2 @@
'use strict'
module.exports = false
+14 -3
View File
@@ -54,9 +54,20 @@ 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`** + project **`build/squid-imports.json`** (generated postinstall) Use the peardock-style packer (not plain `bare-build` CLI):
- **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) ```sh
npm run make:standalone # current host
npm run make:standalone:all # all 64-bit hosts
# → out/flying-jib-<host>/flying-jib[.exe]
```
Script: `scripts/bare-standalone.cjs`
- `bare-pack` + `build/squid-imports.json` + stubs
- Postinstall patches: static Squid plugins, log.js, engines, events polyfill
Verified: standalone binary starts Squid on `127.0.0.1` with empty PATH (no system Node).
Size today ~500MB — optimize later (prune natives / strip).
## Pear deployment layers ## Pear deployment layers
+5 -3
View File
@@ -99,12 +99,14 @@ Stream framing: Hyperswarm/HyperDHT secretstream (already message-oriented for P
| Protocol name | Phase | Purpose | | Protocol name | Phase | Purpose |
|---------------|-------|---------| |---------------|-------|---------|
| `flying-jib/presence` | 3 | Peer id, display name, world/region, heartbeat | | `flying-jib/presence` | 3 | Peer id, display name, heartbeat (**implemented**) |
| `flying-jib/chat` | 3 | Side-channel chat messages | | `flying-jib/chat` | 3 | Side-channel chat messages (**implemented**) |
| `flying-jib/control` | 23 | Session hello, errors, version negotiate | | `flying-jib/control` | 3 | Control messages (channel open; payload TBD) |
| `flying-jib/migrate` | 4 | Border handoff state | | `flying-jib/migrate` | 4 | Border handoff state |
| `flying-jib/registry` | 4 | Optional live region gossip (Autobase is source of truth) | | `flying-jib/registry` | 4 | Optional live region gossip (Autobase is source of truth) |
**Topic:** `hash(worldPublicKey || 'flying-jib/control/v1')` via Hyperswarm (separate from MC TCP tunnel).
### Presence message (logical) ### Presence message (logical)
```json ```json
+238
View File
@@ -0,0 +1,238 @@
'use strict'
/**
* Side-channel peer session for a private world (Phase 3).
* Hyperswarm topic derived from world public key; Protomux channels:
* flying-jib/presence
* flying-jib/chat
* flying-jib/control
*
* Separate from the MC TCP tunnel (ADR-0006 data plane).
*/
const ReadyResource = require('ready-resource')
const Hyperswarm = require('hyperswarm')
const Protomux = require('protomux')
const c = require('compact-encoding')
const b4a = require('b4a')
const crypto = require('hypercore-crypto')
const PROTO_PRESENCE = 'flying-jib/presence'
const PROTO_CHAT = 'flying-jib/chat'
const PROTO_CONTROL = 'flying-jib/control'
const presenceEncoding = c.json
const chatEncoding = c.json
const controlEncoding = c.json
/**
* 32-byte topic for private-world control plane.
* @param {Buffer} worldPublicKey
*/
function controlTopic(worldPublicKey) {
return crypto.hash(b4a.concat([b4a.from(worldPublicKey), b4a.from('flying-jib/control/v1')]))
}
class PeerSession extends ReadyResource {
/**
* @param {object} opts
* @param {Buffer} opts.worldPublicKey
* @param {string} [opts.displayName]
* @param {Buffer} [opts.keyPairSeed] optional stable Noise key seed
*/
constructor(opts) {
super()
if (!opts || !opts.worldPublicKey) throw new Error('PeerSession requires worldPublicKey')
this.worldPublicKey = b4a.from(opts.worldPublicKey)
this.displayName = opts.displayName || 'player'
this.keyPairSeed = opts.keyPairSeed || null
this.swarm = null
this.topic = controlTopic(this.worldPublicKey)
this.peers = new Map() // remotePublicKey hex -> { name, lastSeen, mux }
this._heartbeat = null
this._peerId = null
}
get peerId() {
return this._peerId
}
get peerList() {
const list = []
for (const [id, p] of this.peers) {
list.push({ id, name: p.name, lastSeen: p.lastSeen })
}
return list
}
async _open() {
const keyPair = this.keyPairSeed
? require('hyperdht').keyPair(this.keyPairSeed)
: require('hyperdht').keyPair()
this._peerId = b4a.toString(keyPair.publicKey, 'hex').slice(0, 16)
this.swarm = new Hyperswarm({ keyPair })
this.swarm.on('connection', (conn, info) => this._onConnection(conn, info))
this.swarm.join(this.topic, { server: true, client: true })
await this.swarm.flush()
this._heartbeat = setInterval(() => this._broadcastPresence(), 5000)
if (this._heartbeat.unref) this._heartbeat.unref()
this._broadcastPresence()
}
_onConnection(conn, info) {
const remoteId = b4a.toString(conn.remotePublicKey, 'hex')
const mux = new Protomux(conn)
const presence = mux.createChannel({
protocol: PROTO_PRESENCE,
onopen: () => {},
onclose: () => {}
})
const chat = mux.createChannel({
protocol: PROTO_CHAT,
onopen: () => {},
onclose: () => {}
})
const control = mux.createChannel({
protocol: PROTO_CONTROL,
onopen: () => {},
onclose: () => {}
})
const entry = {
name: remoteId.slice(0, 8),
lastSeen: Date.now(),
mux,
_sendPresence: null,
_sendChat: null
}
this.peers.set(remoteId, entry)
const presenceMsg = presence.addMessage({
encoding: presenceEncoding,
onmessage: (m) => {
const p = this.peers.get(remoteId) || entry
p.name = m.name || p.name
p.lastSeen = Date.now()
this.peers.set(remoteId, p)
this.emit('presence', { peerId: remoteId, ...m })
this.emit('peers', this.peerList)
}
})
const chatMsg = chat.addMessage({
encoding: chatEncoding,
onmessage: (m) => {
this.emit('chat', {
from: m.from || remoteId.slice(0, 8),
peerId: remoteId,
text: String(m.text || ''),
ts: m.ts || Date.now()
})
}
})
control.addMessage({
encoding: controlEncoding,
onmessage: (m) => {
this.emit('control', { peerId: remoteId, ...m })
}
})
presence.open()
chat.open()
control.open()
entry._sendPresence = (payload) => {
try {
presenceMsg.send(payload)
} catch {
/* ignore */
}
}
entry._sendChat = (payload) => {
try {
chatMsg.send(payload)
} catch {
/* ignore */
}
}
// Introduce ourselves
entry._sendPresence({
peerId: this._peerId,
name: this.displayName,
ts: Date.now()
})
conn.on('close', () => {
this.peers.delete(remoteId)
this.emit('peer-leave', { peerId: remoteId })
this.emit('peers', this.peerList)
})
conn.on('error', () => {
/* closed via close */
})
this.emit('peer-join', { peerId: remoteId })
}
_broadcastPresence() {
const payload = {
peerId: this._peerId,
name: this.displayName,
ts: Date.now()
}
for (const p of this.peers.values()) {
if (p._sendPresence) p._sendPresence(payload)
}
}
/**
* @param {string} text
*/
sendChat(text) {
const payload = {
from: this.displayName,
text: String(text).slice(0, 2000),
ts: Date.now()
}
for (const p of this.peers.values()) {
if (p._sendChat) p._sendChat(payload)
}
// Echo locally so UI/CLI sees own messages consistently
this.emit('chat', { ...payload, peerId: this._peerId, local: true })
}
setDisplayName(name) {
this.displayName = String(name || 'player').slice(0, 32)
this._broadcastPresence()
}
async _close() {
if (this._heartbeat) {
clearInterval(this._heartbeat)
this._heartbeat = null
}
if (this.swarm) {
try {
await this.swarm.destroy()
} catch {
/* ignore */
}
this.swarm = null
}
this.peers.clear()
}
}
module.exports = {
PeerSession,
controlTopic,
PROTO_PRESENCE,
PROTO_CHAT,
PROTO_CONTROL
}
+6
View File
@@ -11,6 +11,12 @@ and this project aims to follow [Semantic Versioning](https://semver.org/).
### Added ### Added
- Phase 3 peer communication (CLI):
- Hyperswarm/Protomux presence + chat for private worlds
- Stdin chat on `host`/`join`; `/peers` command
- Standalone packaging:
- `npm run make:standalone` → single Bare executable (no system Node)
- Flying Squid postinstall patches for static plugin load
- Phase 2 private world sharing (core): - Phase 2 private world sharing (core):
- CLI: `host` (share), `join` (client tunnel) - CLI: `host` (share), `join` (client tunnel)
- HyperDHT world tunnel with capability header - HyperDHT world tunnel with capability header
+23 -22
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 largely done · **Phase 2 core working** (host/join tunnel) **Phase:** 13 core working · standalone binary ships Squid without system Node
**Product:** Flying Jib — Bare/Pear P2P Minecraft mesh **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh
--- ---
## One-line summary ## One-line summary
**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. **Single Bare executable** runs Flying Squid on loopback, shares worlds via HyperDHT + `fj1.` invites, and side-channel chat/presence over Hyperswarm/Protomux — **no system Node.js**.
--- ---
@@ -16,13 +16,12 @@
| Area | Status | | Area | Status |
|------|--------| |------|--------|
| Bare app CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working | | CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working |
| Flying Squid on Bare + Node (dev) | Loopback listen verified | | Flying Squid on Bare + standalone binary | Loopback listen verified **without Node on PATH** |
| `fj-bind-guard` | OK | | HyperDHT world tunnel + `fj1.` invites | Working |
| Unit tests (bind-guard, worlds, invite, tunnel) | **10/10 pass** | | Peer chat + presence (Protomux) | Working (tests + CLI stdin chat) |
| HyperDHT world tunnel host↔client | Integration test + CLI smoke | | `npm run make:standalone` | Produces `out/flying-jib-<host>/flying-jib` (~500MB) |
| `fj1.` invite mint/parse | Working | | Unit/integration tests | **12/12 pass** |
| Graceful shutdown (SIGINT re-entrancy guard) | Improved |
--- ---
@@ -30,32 +29,34 @@
| Area | Status | | Area | Status |
|------|--------| |------|--------|
| `bare-build --standalone` release artifact | In progress / unproven | | Binary size optimization | ~500MB — prune later |
| Presence / chat Protomux channels | Phase 3 |
| Mesh enrollment / borders | Phase 4 | | Mesh enrollment / borders | Phase 4 |
| GUI | Not started | | GUI | Not started |
| Production Pear OTA key | Placeholder | | Production Pear OTA key | Placeholder |
| Full Java multiplayer playtest on two machines | Manual | | Two-machine Java playtest | Manual |
--- ---
## How to try (two terminals) ## How to try
```sh ```sh
# Dev
BARE=./node_modules/bare-runtime/bin/bare BARE=./node_modules/bare-runtime/bin/bare
$BARE bin.mjs --storage /tmp/fj-host create demo $BARE bin.mjs host demo --port 25565 --name Alice
$BARE bin.mjs --storage /tmp/fj-host host demo --port 25565 $BARE bin.mjs join 'fj1.…' --port 25566 --name Bob
# copy fj1.… invite # type chat lines; /peers
$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25566 # Standalone (no Node)
# Java → 127.0.0.1:25565 (host) or :25566 (guest) npm run make:standalone
./out/flying-jib-darwin-arm64/flying-jib create demo
./out/flying-jib-darwin-arm64/flying-jib start demo --port 25565
``` ```
--- ---
## Next actions ## Next actions
1. Manual Java Edition multiplayer through tunnel 1. Two-machine Java Edition playtest
2. Finish bare-build standalone CI path 2. Shrink standalone binary / CI matrix for all hosts
3. Phase 3: presence + chat over Protomux 3. Phase 4 mesh registry + border migration
4. Hardening: tunnel connection limits UX, invite rotate
+23
View File
@@ -5,6 +5,29 @@
--- ---
## 2026-07-30 — Phase 3 chat/presence + standalone binary
### Wins
- `lib/peer-session.js` — Hyperswarm + Protomux (`flying-jib/presence|chat|control`)
- CLI host/join: stdin chat, `/peers`, `--name` display name
- Peer chat integration test
- **`scripts/bare-standalone.cjs`** peardock-style pack; **standalone binary runs Squid on 127.0.0.1 with PATH without Node**
- Postinstall patches: static Squid plugins, log.js bare-pack fixes, events polyfill vendor
### Notes
- Standalone ~500MB; size optimization later
- Dev path `bare-runtime/bin/bare bin.mjs` remains preferred for iteration
### Next
- Multi-host make:standalone:all in CI
- Phase 4 mesh
- Java multiplayer playtest
---
## 2026-07-30 — Phase 2 core: HyperDHT tunnel + fj1. invites ## 2026-07-30 — Phase 2 core: HyperDHT tunnel + fj1. invites
### Wins ### Wins
+6 -4
View File
@@ -120,7 +120,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| **Status** | Not Started | | **Status** | In Progress (CLI core done) |
| **Owner** | TBD | | **Owner** | TBD |
| **Target** | TBD | | **Target** | TBD |
| **ADRs** | 0003 (channels) | | **ADRs** | 0003 (channels) |
@@ -133,9 +133,11 @@
### Acceptance ### Acceptance
- [ ] Live peer list for private world session - [x] Live peer list for private world session (`/peers`, presence events)
- [ ] Side-channel chat works when peers are connected - [x] Side-channel chat works when peers are connected (stdin on host/join)
- [ ] Reconnect / heartbeat documented and implemented - [x] Heartbeat presence every 5s
- [ ] GUI peer list
- [ ] Bridge to in-game Squid chat (optional)
### Documentation required to complete ### Documentation required to complete
+155 -5
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"autobase": "^7.28.1",
"b4a": "^1.6.7", "b4a": "^1.6.7",
"bare-assert": "^1.1.0", "bare-assert": "^1.1.0",
"bare-buffer": "^3.3.1", "bare-buffer": "^3.3.1",
@@ -36,16 +37,19 @@
"bare-url": "^2.1.0", "bare-url": "^2.1.0",
"bare-utils": "^1.0.0", "bare-utils": "^1.0.0",
"bare-zlib": "^1.3.0", "bare-zlib": "^1.3.0",
"compact-encoding": "^3.3.0",
"corestore": "^7.9.2", "corestore": "^7.9.2",
"events": "^3.3.0", "events": "^3.3.0",
"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",
"hyperbee": "^2.27.3",
"hypercore-crypto": "^3.4.2", "hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0", "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",
"protomux": "^3.11.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" "z32": "^1.1.0"
@@ -55,6 +59,9 @@
}, },
"devDependencies": { "devDependencies": {
"bare-build": "^1.0.2", "bare-build": "^1.0.2",
"bare-bundle-id": "^1.0.0",
"bare-module-traverse": "^1.0.0",
"bare-pack": "^2.2.1",
"bare-runtime": "1.29.4", "bare-runtime": "1.29.4",
"brittle": "^3.19.0", "brittle": "^3.19.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
@@ -248,6 +255,36 @@
"integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/autobase": {
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/autobase/-/autobase-7.28.1.tgz",
"integrity": "sha512-I8SHcJE/ru5Nun6NpqwRjenB8aK01b9z1qNeyx4XuG+BxeFUpXk0ZT2u3/nG+GZnObUTFMCKOo2fbAv7ipcaLw==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.1",
"bare-events": "^2.2.0",
"compact-encoding": "^3.0.0",
"core-coupler": "^2.0.0",
"debounceify": "^1.0.0",
"encryption-encoding": "^1.0.3",
"hyperbee": "^2.22.0",
"hypercore": "^11.27.12",
"hypercore-crypto": "^3.4.0",
"hypercore-id-encoding": "^1.2.0",
"hyperschema": "^1.12.1",
"index-encoder": "^3.3.2",
"nanoassert": "^2.0.0",
"protomux-wakeup": "^2.0.0",
"ready-resource": "^1.0.0",
"resolve-reject-promise": "^1.1.0",
"safety-catch": "^1.0.2",
"scope-lock": "^1.2.4",
"signal-promise": "^1.0.3",
"sodium-universal": "^5.0.1",
"sub-encoder": "^2.1.1",
"tiny-buffer-map": "^1.1.1"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
@@ -614,6 +651,31 @@
"require-asset": "^1.0.2" "require-asset": "^1.0.2"
} }
}, },
"node_modules/bare-build/node_modules/bare-module-traverse": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-2.4.4.tgz",
"integrity": "sha512-zK4bDxqeD15Tvu/C5B1Mi/epl9PavjIhU3SVh5RhDb4Hpet6applPVX8Q9rinu7nT80yzgSSlBn0exzJaNXv0A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-addon-resolve": "^1.5.0",
"bare-mime": "^1.0.0",
"bare-module-lexer": "^1.6.0",
"bare-module-resolve": "^1.7.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-url": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-url": {
"optional": true
}
}
},
"node_modules/bare-bundle": { "node_modules/bare-bundle": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz", "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz",
@@ -1108,14 +1170,14 @@
} }
}, },
"node_modules/bare-module-traverse": { "node_modules/bare-module-traverse": {
"version": "2.4.4", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-2.4.4.tgz", "resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-1.8.3.tgz",
"integrity": "sha512-zK4bDxqeD15Tvu/C5B1Mi/epl9PavjIhU3SVh5RhDb4Hpet6applPVX8Q9rinu7nT80yzgSSlBn0exzJaNXv0A==", "integrity": "sha512-Iykt1899FOo0lpejEwAUK50fYIzQ18uiJFqybgZvIIqnZSV0/0GhJp+3w3Vx5hTVeTIROF8UmTUB5jHNs65PAg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-addon-resolve": "^1.5.0", "bare-addon-resolve": "^1.5.0",
"bare-mime": "^1.0.0", "bare-module-lexer": "^1.4.0",
"bare-module-lexer": "^1.6.0",
"bare-module-resolve": "^1.7.0" "bare-module-resolve": "^1.7.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -1297,6 +1359,31 @@
} }
} }
}, },
"node_modules/bare-pack/node_modules/bare-module-traverse": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-2.4.4.tgz",
"integrity": "sha512-zK4bDxqeD15Tvu/C5B1Mi/epl9PavjIhU3SVh5RhDb4Hpet6applPVX8Q9rinu7nT80yzgSSlBn0exzJaNXv0A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bare-addon-resolve": "^1.5.0",
"bare-mime": "^1.0.0",
"bare-module-lexer": "^1.6.0",
"bare-module-resolve": "^1.7.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-url": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-url": {
"optional": true
}
}
},
"node_modules/bare-path": { "node_modules/bare-path": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz",
@@ -1956,6 +2043,30 @@
"bare-url": "^2.4.2" "bare-url": "^2.4.2"
} }
}, },
"node_modules/bare-thread/node_modules/bare-module-traverse": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-2.4.4.tgz",
"integrity": "sha512-zK4bDxqeD15Tvu/C5B1Mi/epl9PavjIhU3SVh5RhDb4Hpet6applPVX8Q9rinu7nT80yzgSSlBn0exzJaNXv0A==",
"license": "Apache-2.0",
"dependencies": {
"bare-addon-resolve": "^1.5.0",
"bare-mime": "^1.0.0",
"bare-module-lexer": "^1.6.0",
"bare-module-resolve": "^1.7.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-url": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-url": {
"optional": true
}
}
},
"node_modules/bare-timers": { "node_modules/bare-timers": {
"version": "1.5.2", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/bare-timers/-/bare-timers-1.5.2.tgz", "resolved": "https://registry.npmjs.org/bare-timers/-/bare-timers-1.5.2.tgz",
@@ -2462,6 +2573,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-coupler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/core-coupler/-/core-coupler-2.0.0.tgz",
"integrity": "sha512-FJuEvsdCMwx0Wu+gFQ49rGCi8LCXh8kizHsCQwkdgPZFEFiF0z2HDvyIs+fPt5wMIfU2UVFDuN+dtpfbIxJE6g==",
"license": "Apache-2.0",
"dependencies": {
"safety-catch": "^1.0.2"
}
},
"node_modules/corestore": { "node_modules/corestore": {
"version": "7.12.0", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/corestore/-/corestore-7.12.0.tgz", "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.12.0.tgz",
@@ -2580,6 +2700,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/encryption-encoding": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/encryption-encoding/-/encryption-encoding-1.0.3.tgz",
"integrity": "sha512-+SlKCeULnNnwBF2rGrJlSLYjwITwfjO8PLSHzue4yt+2DsVJUU8GakRkH4+mTaa3FzpxVjpTy1kKR2gYGctNWg==",
"license": "Apache-2.0",
"dependencies": {
"hyperschema": "^1.19.0"
}
},
"node_modules/endian-toggle": { "node_modules/endian-toggle": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz", "resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz",
@@ -3804,6 +3933,18 @@
"unslab": "^1.3.0" "unslab": "^1.3.0"
} }
}, },
"node_modules/protomux-wakeup": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/protomux-wakeup/-/protomux-wakeup-2.9.0.tgz",
"integrity": "sha512-K93WhS9qIRL8WAQPU2V3PxdpQ1oRlQCFyCyIYInoq/iRkcuO1BegRERWo7SS1Tcme14VKGWOr/IivjjYnHan3Q==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.7",
"hypercore-crypto": "^3.5.0",
"hyperschema": "^1.10.4",
"protomux": "^3.10.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4356,6 +4497,15 @@
"integrity": "sha512-IaM+/bh71URhhzlH1qNKNylamK4nxaby2q0qxolWL4Typ3+sIcZHxLe/Mj35b7GOHat/RKpp2vbRepjV6sD+Rw==", "integrity": "sha512-IaM+/bh71URhhzlH1qNKNylamK4nxaby2q0qxolWL4Typ3+sIcZHxLe/Mj35b7GOHat/RKpp2vbRepjV6sD+Rw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-buffer-map": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tiny-buffer-map/-/tiny-buffer-map-1.1.1.tgz",
"integrity": "sha512-C1eDw6ks9CmkDbWVCPHobuixPTkxGa7IDERlaVk98dv4tOUdz42o3haHBr0uhNxbj0gczBTVIyS2uQsu+1vc2Q==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.0"
}
},
"node_modules/tmatch": { "node_modules/tmatch": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/tmatch/-/tmatch-5.0.0.tgz", "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-5.0.0.tgz",
+11 -2
View File
@@ -8,13 +8,15 @@
"bin": "bin.mjs", "bin": "bin.mjs",
"upgrade": "pear://<YOUR_KEY_HERE>", "upgrade": "pear://<YOUR_KEY_HERE>",
"scripts": { "scripts": {
"postinstall": "node scripts/patch-engines-for-bare.js && node scripts/generate-squid-imports.js", "postinstall": "node scripts/patch-engines-for-bare.js && node scripts/patch-flying-squid-plugins.js && node scripts/patch-flying-squid-log.js && node scripts/generate-squid-imports.js",
"start": "node_modules/bare-runtime/bin/bare bin.mjs", "start": "node_modules/bare-runtime/bin/bare bin.mjs",
"start:node": "node bin.mjs", "start:node": "node bin.mjs",
"test": "brittle-node test/*.test.js", "test": "brittle-node test/*.test.js",
"test:bare": "brittle-bare test/*.test.js", "test:bare": "brittle-bare test/*.test.js",
"docs:check": "node -e \"const fs=require('fs');const paths=['living_docs/ROADMAP.md','living_docs/CURRENT_STATUS.md','agent/RULES.md','agent/SECURITY.md','developer_docs/ARCHITECTURE.md','user_docs/README.md','lib/squid-manager.js','bin.mjs'];for (const p of paths){if(!fs.existsSync(p)){console.error('missing',p);process.exit(1)}};console.log('docs+app scaffold ok')\"", "docs:check": "node -e \"const fs=require('fs');const paths=['living_docs/ROADMAP.md','living_docs/CURRENT_STATUS.md','agent/RULES.md','agent/SECURITY.md','developer_docs/ARCHITECTURE.md','user_docs/README.md','lib/squid-manager.js','bin.mjs'];for (const p of paths){if(!fs.existsSync(p)){console.error('missing',p);process.exit(1)}};console.log('docs+app scaffold ok')\"",
"make": "node scripts/make.js", "make": "node scripts/make.js",
"make:standalone": "node scripts/bare-standalone.cjs",
"make:standalone:all": "node scripts/bare-standalone.cjs --host all",
"make:darwin-arm64": "bare-build --name flying-jib --standalone --host darwin-arm64 --out ./out/darwin-arm64 bin.mjs", "make:darwin-arm64": "bare-build --name flying-jib --standalone --host darwin-arm64 --out ./out/darwin-arm64 bin.mjs",
"make:darwin-x64": "bare-build --name flying-jib --standalone --host darwin-x64 --out ./out/darwin-x64 bin.mjs", "make:darwin-x64": "bare-build --name flying-jib --standalone --host darwin-x64 --out ./out/darwin-x64 bin.mjs",
"make:linux-arm64": "bare-build --name flying-jib --standalone --host linux-arm64 --out ./out/linux-arm64 bin.mjs", "make:linux-arm64": "bare-build --name flying-jib --standalone --host linux-arm64 --out ./out/linux-arm64 bin.mjs",
@@ -142,6 +144,8 @@
} }
}, },
"dependencies": { "dependencies": {
"autobase": "^7.28.1",
"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",
@@ -167,23 +171,28 @@
"bare-url": "^2.1.0", "bare-url": "^2.1.0",
"bare-utils": "^1.0.0", "bare-utils": "^1.0.0",
"bare-zlib": "^1.3.0", "bare-zlib": "^1.3.0",
"compact-encoding": "^3.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",
"hyperbee": "^2.27.3",
"hypercore-crypto": "^3.4.2", "hypercore-crypto": "^3.4.2",
"hyperdht": "^6.20.0", "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",
"protomux": "^3.11.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" "z32": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"bare-build": "^1.0.2", "bare-build": "^1.0.2",
"bare-bundle-id": "^1.0.0",
"bare-module-traverse": "^1.0.0",
"bare-pack": "^2.2.1",
"bare-runtime": "1.29.4", "bare-runtime": "1.29.4",
"brittle": "^3.19.0", "brittle": "^3.19.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
/**
* Pack Flying Jib as a Bare standalone executable (peardock pattern).
*
* bare-build CLI does not accept --imports; this script uses bare-pack with
* build/squid-imports.json + package.json imports, then embeds into bare-runtime.
*
* Usage:
* node scripts/bare-standalone.cjs
* node scripts/bare-standalone.cjs --host darwin-arm64
* node scripts/bare-standalone.cjs --host all
*/
'use strict'
const path = require('path')
const fs = require('fs')
const { pathToFileURL } = require('url')
const pack = require('bare-pack')
const { readModule, listPrefix } = require('bare-pack/fs')
const traverse = require('bare-module-traverse')
const id = require('bare-bundle-id')
const root = path.resolve(__dirname, '..')
const pkg = require(path.join(root, 'package.json'))
const ALL_HOSTS = [
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'win32-arm64',
'win32-x64'
]
function parseArgs(argv) {
const out = { hosts: [], outRoot: path.join(root, 'out') }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--host') {
const h = argv[++i]
if (h === 'all') out.hosts.push(...ALL_HOSTS)
else out.hosts.push(h)
} else if (a === '--out') out.outRoot = path.resolve(argv[++i])
else if (a === '--help' || a === '-h') out.help = true
}
if (!out.hosts.length) out.hosts.push(`${process.platform}-${process.arch}`)
return out
}
function fileURL(rel) {
return pathToFileURL(path.join(root, rel)).href
}
function buildImportsMap() {
// Prefer generated squid-imports (bnr + shims)
const squidImportsPath = path.join(root, 'build', 'squid-imports.json')
let map = {}
if (fs.existsSync(squidImportsPath)) {
map = JSON.parse(fs.readFileSync(squidImportsPath, 'utf8'))
} else {
try {
map = { ...require('bare-node-runtime/imports') }
} catch {
map = {}
}
}
// Merge package.json imports (relative bare paths need absolutizing)
const pkgImports = pkg.imports || {}
for (const [spec, target] of Object.entries(pkgImports)) {
if (typeof target === 'string') {
map[spec] = target
continue
}
if (target && typeof target === 'object') {
map[spec] = { ...target }
}
}
for (const [spec, target] of Object.entries(map)) {
if (!target || typeof target !== 'object') continue
if (typeof target.bare === 'string' && target.bare.startsWith('./')) {
map[spec] = {
...target,
bare: fileURL(target.bare.replace(/^\.\//, ''))
}
}
}
// Explicit stubs for optional / browser-only / missing optional deps
const stubs = {
'esbuild-import-glob(path:.,skipFiles:index.js,external.js)':
'build/stubs/esbuild-import-glob.cjs',
'supports-color': 'build/stubs/supports-color.cjs',
'cpu-features': 'build/stubs/supports-color.cjs',
'encoding': 'build/stubs/auto-encoding.cjs',
'bufferutil': 'build/stubs/auto-bufferutil.cjs',
'utf-8-validate': 'build/stubs/auto-utf-8-validate.cjs',
'canvas': 'build/stubs/auto-canvas.cjs',
'sqlite3': 'build/stubs/auto-sqlite3.cjs',
'better-sqlite3': 'build/stubs/auto-better-sqlite3.cjs'
}
for (const [spec, rel] of Object.entries(stubs)) {
map[spec] = { bare: fileURL(rel), default: fileURL(rel) }
}
return map
}
function platformForHost(host) {
const bareBuildRoot = path.dirname(require.resolve('bare-build/package'))
const load = (name) => require(path.join(bareBuildRoot, 'lib', 'platform', name))
if (host.startsWith('darwin') || host.startsWith('ios')) return load('apple')
if (host.startsWith('linux')) return load('linux')
if (host.startsWith('win32')) return load('windows')
if (host.startsWith('android')) return load('android')
throw new Error(`Unknown host '${host}'`)
}
function walkFind(dir, pred) {
const stack = [dir]
while (stack.length) {
const d = stack.pop()
let entries
try {
entries = fs.readdirSync(d, { withFileTypes: true })
} catch {
continue
}
for (const ent of entries) {
const p = path.join(d, ent.name)
if (ent.isDirectory()) stack.push(p)
else if (pred(p)) return p
}
}
return null
}
async function buildOne(host, outRoot) {
const name = pkg.productName ? 'flying-jib' : pkg.name
const outDir = path.join(outRoot, `${name}-${host}`)
fs.rmSync(outDir, { recursive: true, force: true })
fs.mkdirSync(outDir, { recursive: true })
// Ensure postinstall artifacts
require('child_process').execSync('node scripts/generate-squid-imports.js', {
cwd: root,
stdio: 'inherit'
})
require('child_process').execSync('node scripts/patch-engines-for-bare.js', {
cwd: root,
stdio: 'inherit'
})
const entryPath = path.join(root, 'bin.mjs')
const imports = buildImportsMap()
console.log(`[bare-standalone] packing ${name} for ${host}`)
let entry = await pack(
pathToFileURL(entryPath),
{
hosts: [host],
linked: false,
resolve: traverse.resolve.bare,
imports
},
readModule,
listPrefix
)
const baseURL = pathToFileURL(root + path.sep)
entry = entry.unmount(baseURL)
entry.id = id(entry).toString('hex')
const platform = platformForHost(host)
const opts = {
name,
version: pkg.version || '0.0.0',
description: pkg.description || 'Flying Jib',
author: pkg.author || '',
identifier: 'dev.flyingjib.app',
hosts: [host],
out: outDir,
standalone: true,
package: false,
base: root
}
console.log(`[bare-standalone] embedding bare-runtime for ${host}`)
for await (const resource of platform(root, entry, null, opts)) {
if (resource && resource.path) {
console.log(`[bare-standalone] resource ${resource.path}`)
}
}
const binName = host.startsWith('win32') ? `${name}.exe` : name
let binary = path.join(outDir, binName)
if (!fs.existsSync(binary)) {
const found = walkFind(outDir, (f) => {
const base = path.basename(f)
return base === name || base === `${name}.exe` || base === 'flying-jib'
})
if (found) binary = found
}
if (fs.existsSync(binary)) {
try {
fs.chmodSync(binary, 0o755)
} catch {
/* win */
}
const flat = path.join(outDir, path.basename(binary))
if (path.resolve(binary) !== path.resolve(flat)) {
fs.copyFileSync(binary, flat)
binary = flat
}
console.log(`[bare-standalone] wrote ${binary}`)
} else {
console.warn(`[bare-standalone] WARN: binary not found under ${outDir}`)
try {
console.warn(
' contents:',
fs.readdirSync(outDir, { recursive: true }).slice(0, 40).join(', ')
)
} catch {
/* ignore */
}
}
fs.writeFileSync(
path.join(outDir, 'build-info.json'),
JSON.stringify(
{
host,
name,
version: pkg.version,
builtAt: new Date().toISOString(),
entry: 'bin.mjs',
bundleId: entry.id
},
null,
2
) + '\n'
)
return outDir
}
async function main() {
const opts = parseArgs(process.argv.slice(2))
if (opts.help) {
console.log(`Usage: node scripts/bare-standalone.cjs [--host <host>|all] [--out dir]
Hosts: ${ALL_HOSTS.join(', ')}`)
process.exit(0)
}
const results = []
for (const host of opts.hosts) {
results.push(await buildOne(host, opts.outRoot))
}
console.log('[bare-standalone] done:', results.join(', '))
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+9
View File
@@ -12,6 +12,15 @@ const { pathToFileURL } = require('url')
const root = path.resolve(__dirname, '..') const root = path.resolve(__dirname, '..')
const bnr = require('bare-node-runtime/imports') const bnr = require('bare-node-runtime/imports')
// Keep events polyfill vendored for bare-pack (no nested node_modules require)
const eventsSrc = path.join(root, 'node_modules', 'events', 'events.js')
const eventsPoly = path.join(root, 'build', 'shims', 'events-polyfill.cjs')
if (fs.existsSync(eventsSrc)) {
fs.mkdirSync(path.dirname(eventsPoly), { recursive: true })
fs.copyFileSync(eventsSrc, eventsPoly)
}
const eventsShim = pathToFileURL(path.join(root, 'build/shims/node-events.cjs')).href const eventsShim = pathToFileURL(path.join(root, 'build/shims/node-events.cjs')).href
const readlineShim = pathToFileURL(path.join(root, 'build/shims/node-readline.cjs')).href const readlineShim = pathToFileURL(path.join(root, 'build/shims/node-readline.cjs')).href
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
'use strict'
/**
* Neutralize flying-squid log plugin dynamic import('exit-hook') which bare-pack
* does not follow, and soft-fail readline TTY helpers on Bare.
*/
const fs = require('fs')
const path = require('path')
const logPath = path.join(
__dirname,
'..',
'node_modules',
'flying-squid',
'src',
'lib',
'plugins',
'log.js'
)
if (!fs.existsSync(logPath)) {
console.warn('[patch-flying-squid-log] skip (not installed)')
process.exit(0)
}
let src = fs.readFileSync(logPath, 'utf8')
if (src.includes('FLYING_JIB_PATCH: exit-hook removed')) {
console.log('[patch-flying-squid-log] already patched')
process.exit(0)
}
// Replace whole isInNode block's exit-hook import
src = src.replace(
/if \(isInNode\) \{[\s\S]*?readline = require\('readline'\)/,
`if (isInNode) {
/* FLYING_JIB_PATCH: exit-hook removed for bare-pack */
readline = require('readline')`
)
// Soften createInterface / prompt
if (!src.includes('FLYING_JIB_PATCH: readline soft')) {
src = src.replace(
/rl = readline\.createInterface\(\{[\s\S]*?\}\)\s*\n\s*rl\.setPrompt\('> '\)\s*\n\s*rl\.prompt\(true\)/,
`try { /* FLYING_JIB_PATCH: readline soft */
rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
if (rl && typeof rl.setPrompt === 'function') {
rl.setPrompt('> ')
rl.prompt(true)
}
} catch {
rl = undefined
}`
)
}
src = src.replace(
'readline?.cursorTo(process.stdout, 0)',
'(readline && typeof readline.cursorTo === "function" ? readline.cursorTo(process.stdout, 0) : undefined)'
)
fs.writeFileSync(logPath, src)
console.log('[patch-flying-squid-log] patched')
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
'use strict'
/**
* Replace flying-squid dynamic requireindex plugin loading with static requires
* so bare-pack can include the full plugin graph in standalone binaries.
*/
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
const pluginsDir = path.join(root, 'node_modules', 'flying-squid', 'src', 'lib', 'plugins')
const indexPath = path.join(pluginsDir, 'index.js')
if (!fs.existsSync(indexPath)) {
console.warn('[patch-flying-squid-plugins] flying-squid not installed; skip')
process.exit(0)
}
const files = fs
.readdirSync(pluginsDir)
.filter((f) => f.endsWith('.js') && f !== 'index.js')
.sort()
const requires = files
.map((f) => {
const key = './' + f
return ` require(${JSON.stringify(key)})`
})
.join(',\n')
const out = `module.exports.builtinPlugins = null
// Flying Jib postinstall: static plugin list for bare-pack (no readdir at runtime).
module.exports.initPlugins = () => {
if (module.exports.builtinPlugins) return
module.exports.builtinPlugins = [
${requires}
]
}
`
fs.writeFileSync(indexPath, out)
console.log(`[patch-flying-squid-plugins] wrote static list (${files.length} plugins)`)
+53
View File
@@ -0,0 +1,53 @@
'use strict'
const test = require('brittle')
const crypto = require('hypercore-crypto')
const { PeerSession, controlTopic } = require('../lib/peer-session')
test('controlTopic is stable 32 bytes', (t) => {
const k = crypto.randomBytes(32)
const a = controlTopic(k)
const b = controlTopic(k)
t.is(a.byteLength, 32)
t.alike(a, b)
})
test('two PeerSessions exchange chat', { timeout: 45000 }, async (t) => {
const worldKey = crypto.randomBytes(32)
const a = new PeerSession({
worldPublicKey: worldKey,
displayName: 'Alice',
keyPairSeed: crypto.randomBytes(32)
})
const b = new PeerSession({
worldPublicKey: worldKey,
displayName: 'Bob',
keyPairSeed: crypto.randomBytes(32)
})
await a.ready()
await b.ready()
const got = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('chat timeout')), 30000)
b.on('chat', (msg) => {
if (msg.local) return
if (msg.text === 'hello-from-alice') {
clearTimeout(timer)
resolve(msg)
}
})
})
// Wait for swarm to link
await new Promise((r) => setTimeout(r, 3000))
a.sendChat('hello-from-alice')
const msg = await got
t.is(msg.from, 'Alice')
t.is(msg.text, 'hello-from-alice')
await a.close()
await b.close()
})
+5 -1
View File
@@ -30,7 +30,11 @@ Not in the current design. Flying Squid targets **Java Edition** protocol.
## Is chat end-to-end encrypted? ## Is chat end-to-end encrypted?
Peer links use Noise encryption (Hyperswarm/HyperDHT). Side-channel chat rides those links. In-game Minecraft chat is whatever Squid implements on the host world. Peer links use Noise encryption (Hyperswarm/HyperDHT). Side-channel chat (CLI: type while hosting/joined) rides a separate Hyperswarm topic for that world. In-game Minecraft chat is local to the host Squid instance.
## Do I need Node.js for the released app?
**No.** Use the standalone binary from `make:standalone`, or install via Pear when published.
## Will this work on school/work WiFi? ## Will this work on school/work WiFi?