diff --git a/.gitignore b/.gitignore index 74284cc..c124841 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ out/ dist/ deploy/ +# vendored at postinstall +build/shims/events-polyfill.cjs +build/squid-imports.json *.AppImage *.dmg *.snap diff --git a/README.md b/README.md index ab3ffcf..ce4aa4d 100644 --- a/README.md +++ b/README.md @@ -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 # Minecraft Java → 127.0.0.1: +# Chat: type lines while host/join is running; /peers + npm test +npm run make:standalone # single Bare executable → out/flying-jib-/ ``` See [developer_docs/SETUP.md](./developer_docs/SETUP.md) and [living_docs/CURRENT_STATUS.md](./living_docs/CURRENT_STATUS.md). diff --git a/app.js b/app.js index bfbffff..d605624 100644 --- a/app.js +++ b/app.js @@ -11,14 +11,15 @@ 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') +const { PeerSession } = require('./lib/peer-session') /** * Flying Jib Bare application shell. * - Optional pear-runtime worker for OTA (hello-pear-bare pattern) - * - Squid + HyperDHT tunnels run in this process under Bare (ADR-0013) + * - Squid + HyperDHT tunnels + Protomux peer session in this process (ADR-0013) */ module.exports = class App extends ReadyResource { - constructor({ dir, appPath, updates, version, upgrade, name }) { + constructor({ dir, appPath, updates, version, upgrade, name, displayName }) { super() this.dir = dir this.appPath = appPath @@ -26,11 +27,13 @@ module.exports = class App extends ReadyResource { this.version = version this.upgrade = upgrade this.name = name + this.displayName = displayName || 'player' this.IPC = null this.pipe = null this.squid = null this.tunnelHost = null this.tunnelClient = null + this.peerSession = null this.activeWorld = null this._shuttingDown = false } @@ -63,6 +66,20 @@ module.exports = class App extends ReadyResource { } 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) { await this.tunnelClient.close().catch(() => {}) this.tunnelClient = null @@ -76,12 +93,6 @@ module.exports = class App extends ReadyResource { this.squid = null } this.activeWorld = null - const pipe = this.pipe - const IPC = this.IPC - this.pipe = null - this.IPC = null - pipe?.destroy() - IPC?.destroy() } _onWorkerMessage(data) { @@ -106,6 +117,25 @@ module.exports = class App extends ReadyResource { 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() { return listWorlds(this.dir) } @@ -145,10 +175,11 @@ module.exports = class App extends ReadyResource { } /** - * Start Squid + HyperDHT host tunnel; return status + invite string. - * @param {{ world: string, port?: number, version?: string }} opts + * Start Squid + HyperDHT host tunnel + peer chat/presence. + * @param {{ world: string, port?: number, version?: string, displayName?: string }} opts */ async hostWorld(opts) { + if (opts.displayName) this.displayName = opts.displayName const squidStatus = await this.startWorld(opts) const keys = loadOrCreateTunnelKeys(this.dir, this.activeWorld.id) @@ -158,11 +189,13 @@ module.exports = class App extends ReadyResource { cap: keys.cap }) 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)) await this.tunnelHost.ready() + await this._startPeerSession(keys.publicKey) + const invite = encodeInvite({ type: 'private-world', worldKey: keys.publicKeyZ32, @@ -175,18 +208,20 @@ module.exports = class App extends ReadyResource { return { squid: squidStatus, tunnel: this.tunnelHost.status, + peers: this.peerSession ? this.peerSession.peerList : [], invite } } /** - * Join a private world via fj1. invite (client tunnel only; no local Squid). - * @param {{ invite: string, localPort?: number }} opts + * Join a private world via fj1. invite (tunnel + peer session). + * @param {{ invite: string, localPort?: number, displayName?: string }} opts */ async joinWorld(opts) { if (this.tunnelClient) { throw new Error('Already joined a remote world; stop first') } + if (opts.displayName) this.displayName = opts.displayName const inv = decodeInvite(opts.invite) const publicKey = decodeKey(inv.worldKey) const cap = decodeKey(inv.cap) @@ -199,40 +234,35 @@ module.exports = class App extends ReadyResource { this.tunnelClient.on('error', (err) => this.emit('error', err)) await this.tunnelClient.ready() + await this._startPeerSession(publicKey) + return { 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() { - let stopped = false - if (this.tunnelClient) { - await this.tunnelClient.close().catch(() => {}) - this.tunnelClient = null - stopped = true - } - if (this.tunnelHost) { - await this.tunnelHost.close().catch(() => {}) - this.tunnelHost = null - stopped = true - } - if (this.squid) { - await this.squid.close().catch(() => {}) - this.squid = null - stopped = true - } - this.activeWorld = null - return stopped + await this._teardownSession() + return true } getStatus() { return { storage: this.dir, world: this.activeWorld, + displayName: this.displayName, squid: this.squid ? this.squid.status : { running: false }, 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 } } diff --git a/bin.mjs b/bin.mjs index f33dab0..8320502 100644 --- a/bin.mjs +++ b/bin.mjs @@ -14,13 +14,17 @@ import { createRequire } from 'module' import { command, flag, arg, summary, header, footer } from 'paparam' import path from 'path' import process from 'process' -import { fileURLToPath } from 'url' import pkg from './package.json' with { type: 'json' } import App from './app.js' -const require = createRequire(import.meta.url) -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) +// createRequire needs a file: URL in Node; under bare-pack standalone, import.meta.url +// may be app.bundle — fall back to a dummy path for require() of CJS shims. +let require +try { + require = createRequire(import.meta.url) +} catch { + require = createRequire('file:///flying-jib/bin.mjs') +} const appName = pkg.productName || pkg.name const isBare = typeof Bare !== 'undefined' @@ -48,17 +52,72 @@ function exit(code) { else process.exit(code) } -function makeApp(storage, updates = false) { +function makeApp(storage, updates = false, displayName) { return new App({ dir: storage, appPath: isDev ? null : argv0, updates, version: pkg.version, 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 */ function attachShutdown(app) { let stopping = false @@ -182,25 +241,29 @@ const hostCmd = command( arg('', 'world name / id'), flag('--port|-p [port]', 'local Minecraft port (default 25565)'), flag('--version [ver]', 'override Minecraft version'), + flag('--name|-n [display]', 'chat display name'), async (cmd) => { 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('error', (err) => console.error('[error]', err)) attachShutdown(app) + attachPeerUi(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 + version: cmd.flags.version, + displayName: cmd.flags.name }) 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(` chat: type lines + Enter; /peers for list`) console.log('') console.log('Invite (treat as secret — fj1. capability):') console.log(result.invite) @@ -226,18 +289,21 @@ const joinCmd = command( summary('Join a private world from an fj1. invite'), arg('', 'fj1. invite string'), flag('--port|-p [port]', 'local bind port (default: ephemeral)'), + flag('--name|-n [display]', 'chat display name'), async (cmd) => { 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('error', (err) => console.error('[error]', err)) attachShutdown(app) + attachPeerUi(app) await app.ready() try { const result = await app.joinWorld({ 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 console.log('') @@ -245,6 +311,7 @@ const joinCmd = command( console.log(` name: ${result.invite.name || '(unnamed)'}`) console.log(` version: ${result.invite.mcVersion || 'unknown'}`) console.log(` local: 127.0.0.1:${port}`) + console.log(` chat: type lines + Enter; /peers for list`) console.log('') console.log('Connect Java Edition to:') console.log(` 127.0.0.1:${port}`) @@ -297,6 +364,3 @@ if (root.flags.version) { console.log(`${appName} v${pkg.version}`) exit(0) } - -void __dirname -void __filename diff --git a/build/shims/node-events.cjs b/build/shims/node-events.cjs index a3006e9..2792995 100644 --- a/build/shims/node-events.cjs +++ b/build/shims/node-events.cjs @@ -1,12 +1,7 @@ 'use strict' /** - * Flying Squid / readable-stream use `EventEmitter.call(this)` (util.inherits). - * bare-events is an ES class (throws without `new`). - * - * Load the classic npm `events` package by absolute path so we do not recurse - * through package.json / bare-node-runtime "events" import maps. + * Callable EventEmitter for Flying Squid / readable-stream (util.inherits). + * Vendored polyfill is inlined so bare-pack standalone does not need nested requires. */ -const path = require('path') -const eventsJs = path.join(__dirname, '..', '..', 'node_modules', 'events', 'events.js') -module.exports = require(eventsJs) +module.exports = require('./events-polyfill.cjs') diff --git a/build/stubs/auto-better-sqlite3.cjs b/build/stubs/auto-better-sqlite3.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-better-sqlite3.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/auto-bufferutil.cjs b/build/stubs/auto-bufferutil.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-bufferutil.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/auto-canvas.cjs b/build/stubs/auto-canvas.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-canvas.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/auto-encoding.cjs b/build/stubs/auto-encoding.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-encoding.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/auto-sqlite3.cjs b/build/stubs/auto-sqlite3.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-sqlite3.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/auto-utf-8-validate.cjs b/build/stubs/auto-utf-8-validate.cjs new file mode 100644 index 0000000..67a0205 --- /dev/null +++ b/build/stubs/auto-utf-8-validate.cjs @@ -0,0 +1 @@ +'use strict'; module.exports = {} diff --git a/build/stubs/empty.cjs b/build/stubs/empty.cjs new file mode 100644 index 0000000..f177540 --- /dev/null +++ b/build/stubs/empty.cjs @@ -0,0 +1,2 @@ +'use strict' +module.exports = {} diff --git a/build/stubs/supports-color.cjs b/build/stubs/supports-color.cjs new file mode 100644 index 0000000..35191e6 --- /dev/null +++ b/build/stubs/supports-color.cjs @@ -0,0 +1,2 @@ +'use strict' +module.exports = false diff --git a/developer_docs/BUILD_AND_RELEASE.md b/developer_docs/BUILD_AND_RELEASE.md index 34498d7..9095d60 100644 --- a/developer_docs/BUILD_AND_RELEASE.md +++ b/developer_docs/BUILD_AND_RELEASE.md @@ -54,9 +54,20 @@ Optional packaged forms (`.AppImage`, `.pkg`, `.msix`) via bare-build package mo ## Flying Squid in the bundle -- 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) +Use the peardock-style packer (not plain `bare-build` CLI): + +```sh +npm run make:standalone # current host +npm run make:standalone:all # all 64-bit hosts +# → out/flying-jib-/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 diff --git a/developer_docs/PROTOCOL.md b/developer_docs/PROTOCOL.md index 90a85ab..cbb5851 100644 --- a/developer_docs/PROTOCOL.md +++ b/developer_docs/PROTOCOL.md @@ -99,12 +99,14 @@ Stream framing: Hyperswarm/HyperDHT secretstream (already message-oriented for P | Protocol name | Phase | Purpose | |---------------|-------|---------| -| `flying-jib/presence` | 3 | Peer id, display name, world/region, heartbeat | -| `flying-jib/chat` | 3 | Side-channel chat messages | -| `flying-jib/control` | 2–3 | Session hello, errors, version negotiate | +| `flying-jib/presence` | 3 | Peer id, display name, heartbeat (**implemented**) | +| `flying-jib/chat` | 3 | Side-channel chat messages (**implemented**) | +| `flying-jib/control` | 3 | Control messages (channel open; payload TBD) | | `flying-jib/migrate` | 4 | Border handoff state | | `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) ```json diff --git a/lib/peer-session.js b/lib/peer-session.js new file mode 100644 index 0000000..fbe7ffc --- /dev/null +++ b/lib/peer-session.js @@ -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 +} diff --git a/living_docs/CHANGELOG.md b/living_docs/CHANGELOG.md index fe1b55f..6de63be 100644 --- a/living_docs/CHANGELOG.md +++ b/living_docs/CHANGELOG.md @@ -11,6 +11,12 @@ and this project aims to follow [Semantic Versioning](https://semver.org/). ### 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): - CLI: `host` (share), `join` (client tunnel) - HyperDHT world tunnel with capability header diff --git a/living_docs/CURRENT_STATUS.md b/living_docs/CURRENT_STATUS.md index 3f2f21b..515c179 100644 --- a/living_docs/CURRENT_STATUS.md +++ b/living_docs/CURRENT_STATUS.md @@ -1,14 +1,14 @@ # Flying Jib — Current Status **Snapshot date:** 2026-07-30 -**Phase:** 1 largely done · **Phase 2 core working** (host/join tunnel) +**Phase:** 1–3 core working · standalone binary ships Squid without system Node **Product:** Flying Jib — Bare/Pear P2P Minecraft mesh --- ## 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 | |------|--------| -| 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 | +| CLI (`create` / `list` / `start` / `host` / `join` / `status`) | Working | +| Flying Squid on Bare + standalone binary | Loopback listen verified **without Node on PATH** | +| HyperDHT world tunnel + `fj1.` invites | Working | +| Peer chat + presence (Protomux) | Working (tests + CLI stdin chat) | +| `npm run make:standalone` | Produces `out/flying-jib-/flying-jib` (~500MB) | +| Unit/integration tests | **12/12 pass** | --- @@ -30,32 +29,34 @@ | Area | Status | |------|--------| -| `bare-build --standalone` release artifact | In progress / unproven | -| Presence / chat Protomux channels | Phase 3 | +| Binary size optimization | ~500MB — prune later | | Mesh enrollment / borders | Phase 4 | | GUI | Not started | | 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 +# Dev 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 +$BARE bin.mjs host demo --port 25565 --name Alice +$BARE bin.mjs join 'fj1.…' --port 25566 --name Bob +# type chat lines; /peers -$BARE bin.mjs --storage /tmp/fj-guest join 'fj1.…' --port 25566 -# Java → 127.0.0.1:25565 (host) or :25566 (guest) +# Standalone (no Node) +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 -1. Manual Java Edition multiplayer through tunnel -2. Finish bare-build standalone CI path -3. Phase 3: presence + chat over Protomux -4. Hardening: tunnel connection limits UX, invite rotate +1. Two-machine Java Edition playtest +2. Shrink standalone binary / CI matrix for all hosts +3. Phase 4 mesh registry + border migration + diff --git a/living_docs/PROGRESS.md b/living_docs/PROGRESS.md index 27c7026..c9d85cc 100644 --- a/living_docs/PROGRESS.md +++ b/living_docs/PROGRESS.md @@ -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 ### Wins diff --git a/living_docs/ROADMAP.md b/living_docs/ROADMAP.md index 136f408..786728c 100644 --- a/living_docs/ROADMAP.md +++ b/living_docs/ROADMAP.md @@ -120,7 +120,7 @@ | Field | Value | |-------|-------| -| **Status** | Not Started | +| **Status** | In Progress (CLI core done) | | **Owner** | TBD | | **Target** | TBD | | **ADRs** | 0003 (channels) | @@ -133,9 +133,11 @@ ### Acceptance -- [ ] Live peer list for private world session -- [ ] Side-channel chat works when peers are connected -- [ ] Reconnect / heartbeat documented and implemented +- [x] Live peer list for private world session (`/peers`, presence events) +- [x] Side-channel chat works when peers are connected (stdin on host/join) +- [x] Heartbeat presence every 5s +- [ ] GUI peer list +- [ ] Bridge to in-game Squid chat (optional) ### Documentation required to complete diff --git a/package-lock.json b/package-lock.json index 7d410e1..aae9afa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "autobase": "^7.28.1", "b4a": "^1.6.7", "bare-assert": "^1.1.0", "bare-buffer": "^3.3.1", @@ -36,16 +37,19 @@ "bare-url": "^2.1.0", "bare-utils": "^1.0.0", "bare-zlib": "^1.3.0", + "compact-encoding": "^3.3.0", "corestore": "^7.9.2", "events": "^3.3.0", "flying-squid": "^1.12.0", "framed-stream": "^1.0.1", "graceful-goodbye": "^1.3.3", + "hyperbee": "^2.27.3", "hypercore-crypto": "^3.4.2", "hyperdht": "^6.20.0", "hyperswarm": "^4.17.0", "paparam": "^1.10.1", "pear-runtime": "^1.2.0", + "protomux": "^3.11.0", "ready-resource": "^1.2.0", "which-runtime": "^1.4.0", "z32": "^1.1.0" @@ -55,6 +59,9 @@ }, "devDependencies": { "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", "brittle": "^3.19.0", "prettier": "^3.6.2", @@ -248,6 +255,36 @@ "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", "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": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -614,6 +651,31 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz", @@ -1108,14 +1170,14 @@ } }, "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==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-1.8.3.tgz", + "integrity": "sha512-Iykt1899FOo0lpejEwAUK50fYIzQ18uiJFqybgZvIIqnZSV0/0GhJp+3w3Vx5hTVeTIROF8UmTUB5jHNs65PAg==", + "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-lexer": "^1.4.0", "bare-module-resolve": "^1.7.0" }, "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", @@ -1956,6 +2043,30 @@ "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": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/bare-timers/-/bare-timers-1.5.2.tgz", @@ -2462,6 +2573,15 @@ "dev": true, "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": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.12.0.tgz", @@ -2580,6 +2700,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "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": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz", @@ -3804,6 +3933,18 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4356,6 +4497,15 @@ "integrity": "sha512-IaM+/bh71URhhzlH1qNKNylamK4nxaby2q0qxolWL4Typ3+sIcZHxLe/Mj35b7GOHat/RKpp2vbRepjV6sD+Rw==", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-5.0.0.tgz", diff --git a/package.json b/package.json index df3cbcc..8dd07e6 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,15 @@ "bin": "bin.mjs", "upgrade": "pear://", "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": "node bin.mjs", "test": "brittle-node 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')\"", "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-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", @@ -142,6 +144,8 @@ } }, "dependencies": { + "autobase": "^7.28.1", + "b4a": "^1.6.7", "bare-assert": "^1.1.0", "bare-buffer": "^3.3.1", "bare-console": "^6.0.1", @@ -167,23 +171,28 @@ "bare-url": "^2.1.0", "bare-utils": "^1.0.0", "bare-zlib": "^1.3.0", + "compact-encoding": "^3.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", + "hyperbee": "^2.27.3", "hypercore-crypto": "^3.4.2", "hyperdht": "^6.20.0", "hyperswarm": "^4.17.0", "paparam": "^1.10.1", "pear-runtime": "^1.2.0", + "protomux": "^3.11.0", "ready-resource": "^1.2.0", "which-runtime": "^1.4.0", "z32": "^1.1.0" }, "devDependencies": { "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", "brittle": "^3.19.0", "prettier": "^3.6.2", diff --git a/scripts/bare-standalone.cjs b/scripts/bare-standalone.cjs new file mode 100644 index 0000000..582efa1 --- /dev/null +++ b/scripts/bare-standalone.cjs @@ -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 |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) +}) diff --git a/scripts/generate-squid-imports.js b/scripts/generate-squid-imports.js index f999684..98d9460 100644 --- a/scripts/generate-squid-imports.js +++ b/scripts/generate-squid-imports.js @@ -12,6 +12,15 @@ const { pathToFileURL } = require('url') const root = path.resolve(__dirname, '..') 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 readlineShim = pathToFileURL(path.join(root, 'build/shims/node-readline.cjs')).href diff --git a/scripts/patch-flying-squid-log.js b/scripts/patch-flying-squid-log.js new file mode 100644 index 0000000..cf06105 --- /dev/null +++ b/scripts/patch-flying-squid-log.js @@ -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') diff --git a/scripts/patch-flying-squid-plugins.js b/scripts/patch-flying-squid-plugins.js new file mode 100644 index 0000000..d622721 --- /dev/null +++ b/scripts/patch-flying-squid-plugins.js @@ -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)`) diff --git a/test/peer-session.test.js b/test/peer-session.test.js new file mode 100644 index 0000000..95b89e6 --- /dev/null +++ b/test/peer-session.test.js @@ -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() +}) diff --git a/user_docs/FAQ.md b/user_docs/FAQ.md index 2c59305..f9159d0 100644 --- a/user_docs/FAQ.md +++ b/user_docs/FAQ.md @@ -30,7 +30,11 @@ Not in the current design. Flying Squid targets **Java Edition** protocol. ## 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 Wi‑Fi?