first commit
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import Hyperswarm from 'hyperswarm'
|
||||
import Protomux from 'protomux'
|
||||
import b4a from 'b4a'
|
||||
import safetyCatch from 'safety-catch'
|
||||
import Hyperdrive from 'hyperdrive'
|
||||
import Corestore from 'corestore'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { topicKey, parseMbr } from 'bare-os-protocol'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.join(__dirname, '..', '..')
|
||||
import { SwarmDisk } from './lib/swarm-disk.js'
|
||||
import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
|
||||
|
||||
function bootStorePath() {
|
||||
return process.env.BARE_OS_BOOT_STORE || path.join(repoRoot, 'data', 'corestore-booter')
|
||||
}
|
||||
|
||||
async function createReadLine() {
|
||||
if (process.env.BARE_OS_SKIP_REPL === '1') {
|
||||
return async () => null
|
||||
}
|
||||
try {
|
||||
const { createInterface } = await import('node:readline')
|
||||
return (prompt) =>
|
||||
new Promise((resolve) => {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
rl.question(prompt, (line) => {
|
||||
rl.close()
|
||||
resolve(line)
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
console.warn(
|
||||
'[bare-os-booter] No node:readline; exiting REPL. Set BARE_OS_SKIP_REPL=1 for non-interactive kernel or run under Node.'
|
||||
)
|
||||
return async () => null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SwarmDisk} disk
|
||||
* @param {import('corestore').default} store
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {Uint8Array} initSource
|
||||
*/
|
||||
async function executeKernel(disk, store, swarm, initSource) {
|
||||
const readLine = await createReadLine()
|
||||
|
||||
const ctx = {
|
||||
disk,
|
||||
drive: disk.drive,
|
||||
personalDrive: disk.personalDrive,
|
||||
console,
|
||||
b4a,
|
||||
topic: topicKey(),
|
||||
readLine,
|
||||
async execLine(line) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (!parts[0]) return
|
||||
await runBinCommand(disk.drive, parts, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
disk.os = {
|
||||
async searchLocal() {
|
||||
return []
|
||||
},
|
||||
async execRpc() {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
await runKernelFromSource(b4a.toString(initSource), ctx)
|
||||
}
|
||||
|
||||
async function bootFromPeers(disk, store, swarm) {
|
||||
const topic = topicKey()
|
||||
console.log('Loading MBR from peers...')
|
||||
const mbr = await disk.read(0)
|
||||
const { keys } = parseMbr(mbr)
|
||||
|
||||
let initSource = null
|
||||
for (const driveKey of keys) {
|
||||
try {
|
||||
console.log('Mounting drive', b4a.toString(driveKey, 'hex').slice(0, 16) + '...')
|
||||
disk.drive = new Hyperdrive(store, driveKey)
|
||||
await disk.drive.ready()
|
||||
|
||||
for (const peer of disk.peers) {
|
||||
disk.drive.replicate(peer.mux.stream, { live: true, download: true })
|
||||
}
|
||||
swarm.join(disk.drive.discoveryKey)
|
||||
const done = disk.drive.findingPeers()
|
||||
swarm.flush().then(done, done)
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
initSource = await disk.drive.get('/boot/init.js')
|
||||
if (initSource) break
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
if (initSource) break
|
||||
console.log('Kernel not ready on this drive key, trying next...')
|
||||
} catch (err) {
|
||||
console.log('Drive error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!initSource) throw new Error('Kernel not found after replication')
|
||||
|
||||
await disk.initPersonalDrive(store, swarm, Hyperdrive)
|
||||
console.log('Starting kernel...')
|
||||
await executeKernel(disk, store, swarm, initSource)
|
||||
}
|
||||
|
||||
async function bootLocal(disk, store, swarm) {
|
||||
const seedPath =
|
||||
process.env.BARE_OS_LOCAL_SEED || path.join(repoRoot, 'data', 'corestore-seeder')
|
||||
console.log('Local boot from', seedPath)
|
||||
const seedStore = new Corestore(seedPath)
|
||||
disk.drive = new Hyperdrive(seedStore)
|
||||
await disk.drive.ready()
|
||||
|
||||
let initSource = null
|
||||
for (let i = 0; i < 10; i++) {
|
||||
initSource = await disk.drive.get('/boot/init.js')
|
||||
if (initSource) break
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
if (!initSource) throw new Error('Kernel missing in local seed store')
|
||||
|
||||
await disk.initPersonalDrive(store, swarm, Hyperdrive)
|
||||
await executeKernel(disk, store, swarm, initSource)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('--- bare-os-booter ---')
|
||||
|
||||
const store = new Corestore(bootStorePath())
|
||||
const swarm = new Hyperswarm()
|
||||
const disk = new SwarmDisk()
|
||||
const topic = topicKey()
|
||||
|
||||
swarm.on('connection', (socket) => {
|
||||
const mux = new Protomux(socket)
|
||||
disk.addPeer(mux, socket)
|
||||
})
|
||||
|
||||
swarm.join(topic)
|
||||
|
||||
const maxWait = Number(process.env.BARE_OS_PEER_WAIT_MS || 8000)
|
||||
let waited = 0
|
||||
while (disk.peers.size === 0 && waited < maxWait) {
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
waited += 500
|
||||
}
|
||||
|
||||
console.log('Peers:', disk.peers.size)
|
||||
|
||||
try {
|
||||
if (disk.peers.size > 0) {
|
||||
await bootFromPeers(disk, store, swarm)
|
||||
} else {
|
||||
await bootLocal(disk, store, swarm)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (disk.personalDrive) await disk.personalDrive.close()
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (disk.drive) await disk.drive.close()
|
||||
} catch (_) {}
|
||||
try {
|
||||
await swarm.destroy()
|
||||
} catch (_) {}
|
||||
try {
|
||||
await store.close()
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(safetyCatch)
|
||||
@@ -0,0 +1,38 @@
|
||||
import b4a from 'b4a'
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
/**
|
||||
* Execute kernel source from Hyperdrive (trusted). Expects top-level `async function start(ctx)`.
|
||||
* @param {string} source
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function runKernelFromSource(source, ctx) {
|
||||
const fn = new AsyncFunction(
|
||||
'ctx',
|
||||
`${source}\nif (typeof start !== 'function') throw new Error('kernel must define async function start')\nreturn start(ctx)\n`
|
||||
)
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run /bin/<cmd> script with `async function run(ctx, argv)`.
|
||||
* @param {import('hyperdrive').default} drive
|
||||
* @param {string[]} argv
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function runBinCommand(drive, argv, ctx) {
|
||||
const cmd = argv[0]
|
||||
const buf = await drive.get('/bin/' + cmd)
|
||||
if (!buf) {
|
||||
ctx.console.log('unknown command: ' + cmd)
|
||||
return
|
||||
}
|
||||
const src = b4a.toString(buf)
|
||||
const fn = new AsyncFunction(
|
||||
'ctx',
|
||||
'argv',
|
||||
`${src}\nif (typeof run !== 'function') throw new Error('missing run() in /bin/${cmd}')\nreturn run(ctx, argv)\n`
|
||||
)
|
||||
return fn(ctx, argv)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import b4a from 'b4a'
|
||||
import c from 'compact-encoding'
|
||||
import { PROTOCOL_NAME } from 'bare-os-protocol/constants.js'
|
||||
|
||||
export class SwarmDisk {
|
||||
constructor() {
|
||||
this.localRAM = new Map()
|
||||
this.peers = new Set()
|
||||
this.pendingReads = new Map()
|
||||
this.pendingSearches = new Map()
|
||||
this.pendingRpc = new Map()
|
||||
this.searchIdCounter = 0
|
||||
this.rpcIdCounter = 0
|
||||
this.drive = null
|
||||
this.personalDrive = null
|
||||
this.os = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('corestore').default} store
|
||||
* @param {import('hyperswarm').default} swarm
|
||||
* @param {import('hyperdrive').default} Hyperdrive
|
||||
*/
|
||||
async initPersonalDrive(store, swarm, Hyperdrive) {
|
||||
const localStore = store.namespace('bare-os-personal-v1')
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
if (!this.personalDrive.writable) {
|
||||
this.personalDrive = new Hyperdrive(localStore)
|
||||
await this.personalDrive.ready()
|
||||
}
|
||||
console.log('Personal drive:', this.personalDrive.id.slice(0, 16) + '...')
|
||||
swarm.join(this.personalDrive.discoveryKey)
|
||||
}
|
||||
|
||||
addPeer(mux, socket) {
|
||||
const disk = this
|
||||
/** @type {any} */
|
||||
let chan
|
||||
|
||||
const context = {
|
||||
onread(index) {
|
||||
const data = disk.localRAM.get(index)
|
||||
if (data) chan.messages[1].send({ index, data })
|
||||
},
|
||||
ondata(m) {
|
||||
const cb = disk.pendingReads.get(m.index)
|
||||
if (cb) {
|
||||
disk.pendingReads.delete(m.index)
|
||||
cb(m.data)
|
||||
}
|
||||
},
|
||||
ongossip() {},
|
||||
async onsearchreq(m) {
|
||||
const matches = disk.os ? await disk.os.searchLocal?.(m.query) : []
|
||||
chan.messages[4].send({ id: m.id, matches: matches || [] })
|
||||
},
|
||||
onsearchres(m) {
|
||||
const cb = disk.pendingSearches.get(m.id)
|
||||
if (cb) {
|
||||
disk.pendingSearches.delete(m.id)
|
||||
cb(m.matches)
|
||||
}
|
||||
},
|
||||
async onrpcreq(m) {
|
||||
if (!disk.os) {
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: false,
|
||||
result: '',
|
||||
error: 'OS not initialized'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await disk.os.execRpc?.(m.module, m.method, m.args)
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: true,
|
||||
result: String(result ?? ''),
|
||||
error: ''
|
||||
})
|
||||
} catch (err) {
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: false,
|
||||
result: '',
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
},
|
||||
onrpcres(m) {
|
||||
const cb = disk.pendingRpc.get(m.id)
|
||||
if (cb) {
|
||||
disk.pendingRpc.delete(m.id)
|
||||
cb(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chan = mux.createChannel({ protocol: PROTOCOL_NAME, userData: context })
|
||||
|
||||
chan.addMessage({
|
||||
encoding: c.uint32,
|
||||
onmessage: (index, ch) => ch.userData.onread(index)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.index)
|
||||
c.buffer.preencode(state, m.data)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.index)
|
||||
c.buffer.encode(state, m.data)
|
||||
},
|
||||
decode(state) {
|
||||
return { index: c.uint32.decode(state), data: c.buffer.decode(state) }
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.ondata(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: c.buffer,
|
||||
onmessage: (bitfield, ch) => ch.userData.ongossip(bitfield)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.query)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.query)
|
||||
},
|
||||
decode(state) {
|
||||
return { id: c.uint32.decode(state), query: c.string.decode(state) }
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onsearchreq(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.array(c.string).preencode(state, m.matches)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.array(c.string).encode(state, m.matches)
|
||||
},
|
||||
decode(state) {
|
||||
return { id: c.uint32.decode(state), matches: c.array(c.string).decode(state) }
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onsearchres(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.module)
|
||||
c.string.preencode(state, m.method)
|
||||
c.array(c.string).preencode(state, m.args)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.module)
|
||||
c.string.encode(state, m.method)
|
||||
c.array(c.string).encode(state, m.args)
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
module: c.string.decode(state),
|
||||
method: c.string.decode(state),
|
||||
args: c.array(c.string).decode(state)
|
||||
}
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onrpcreq(m)
|
||||
})
|
||||
chan.addMessage({
|
||||
encoding: {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.bool.preencode(state, m.success)
|
||||
c.string.preencode(state, m.result)
|
||||
c.string.preencode(state, m.error)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.bool.encode(state, m.success)
|
||||
c.string.encode(state, m.result || '')
|
||||
c.string.encode(state, m.error || '')
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
success: c.bool.decode(state),
|
||||
result: c.string.decode(state),
|
||||
error: c.string.decode(state)
|
||||
}
|
||||
}
|
||||
},
|
||||
onmessage: (m, ch) => ch.userData.onrpcres(m)
|
||||
})
|
||||
|
||||
chan.open()
|
||||
|
||||
const peer = { chan, mux, socket, id: null }
|
||||
this.peers.add(peer)
|
||||
|
||||
const setPeerId = () => {
|
||||
if (socket.remotePublicKey && !peer.id) {
|
||||
peer.id = b4a.toString(socket.remotePublicKey, 'hex')
|
||||
return true
|
||||
}
|
||||
if (socket.handshakeHash && !peer.id) {
|
||||
peer.id = b4a.toString(socket.handshakeHash, 'hex')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
socket.on('handshake', setPeerId)
|
||||
if (!setPeerId()) {
|
||||
let attempts = 0
|
||||
const tryAgain = () => {
|
||||
attempts++
|
||||
if (attempts > 10) {
|
||||
if (!peer.id) peer.id = 'peer-' + Date.now().toString(36)
|
||||
return
|
||||
}
|
||||
if (!setPeerId()) setTimeout(tryAgain, attempts * 50)
|
||||
}
|
||||
setTimeout(tryAgain, 50)
|
||||
}
|
||||
|
||||
mux.stream.on('close', () => {
|
||||
this.peers.delete(peer)
|
||||
})
|
||||
|
||||
if (this.drive) this.drive.replicate(mux.stream, { live: true, download: true })
|
||||
if (this.personalDrive) this.personalDrive.replicate(mux.stream)
|
||||
}
|
||||
|
||||
async read(index) {
|
||||
if (this.localRAM.has(index)) return this.localRAM.get(index)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('MBR read timeout')), 10000)
|
||||
this.pendingReads.set(index, (data) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(data)
|
||||
})
|
||||
for (const peer of this.peers) peer.chan.messages[0].send(index)
|
||||
})
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
const id = this.searchIdCounter++
|
||||
const promises = []
|
||||
for (const peer of this.peers) {
|
||||
promises.push(
|
||||
new Promise((resolve) => {
|
||||
const to = setTimeout(() => resolve([]), 3000)
|
||||
this.pendingSearches.set(id, (matches) => {
|
||||
clearTimeout(to)
|
||||
resolve(matches)
|
||||
})
|
||||
peer.chan.messages[3].send({ id, query })
|
||||
})
|
||||
)
|
||||
}
|
||||
const results = await Promise.all(promises)
|
||||
return results.flat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "bare-os-booter",
|
||||
"version": "0.1.0",
|
||||
"description": "Pear/Bare booter: Hyperswarm client, MBR, system + personal Hyperdrive, kernel",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"start": "bare index.js",
|
||||
"dev": "bare index.js",
|
||||
"test": "brittle-node test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bare-os-protocol": "*",
|
||||
"b4a": "^1.6.7",
|
||||
"compact-encoding": "^2.18.0",
|
||||
"corestore": "^7.2.1",
|
||||
"hyperdrive": "^13.3.2",
|
||||
"hyperswarm": "^4.16.0",
|
||||
"protomux": "^3.10.1",
|
||||
"safety-catch": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"brittle": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=2.0.0"
|
||||
},
|
||||
"pear": {
|
||||
"name": "bare-os-booter",
|
||||
"stage": {
|
||||
"ignore": [
|
||||
".git",
|
||||
"test",
|
||||
"coverage",
|
||||
".DS_Store",
|
||||
"node_modules/.bin",
|
||||
"node_modules/.package-lock.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"imports": {
|
||||
"path": {
|
||||
"bare": "bare-path",
|
||||
"default": "node:path"
|
||||
},
|
||||
"url": {
|
||||
"bare": "bare-url",
|
||||
"default": "node:url"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'brittle'
|
||||
import b4a from 'b4a'
|
||||
import Hyperdrive from 'hyperdrive'
|
||||
import Corestore from 'corestore'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function testCorestoreDir(name) {
|
||||
const dir = path.join(__dirname, '.test-data', name + '-' + process.pid + '-' + Math.random().toString(36).slice(2))
|
||||
mkdirSync(path.dirname(dir), { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
test('runKernelFromSource invokes start(ctx)', async (t) => {
|
||||
const calls = []
|
||||
const source = `
|
||||
async function start(ctx) {
|
||||
ctx.calls.push('ok')
|
||||
}
|
||||
`
|
||||
await runKernelFromSource(source, { calls })
|
||||
t.is(calls[0], 'ok')
|
||||
})
|
||||
|
||||
test('runBinCommand runs /bin helper', async (t) => {
|
||||
const dir = testCorestoreDir('bin')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
await drive.ready()
|
||||
await drive.put(
|
||||
'/bin/hello',
|
||||
b4a.from(`
|
||||
async function run(ctx, argv) {
|
||||
ctx.out.push(argv.join(' '))
|
||||
}
|
||||
`)
|
||||
)
|
||||
const out = []
|
||||
await runBinCommand(drive, ['hello', 'a', 'b'], { out, console })
|
||||
t.is(out[0], 'hello a b')
|
||||
await store.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('Hyperdrive roundtrips /boot/init.js on Corestore', async (t) => {
|
||||
const dir = testCorestoreDir('seed')
|
||||
const store = new Corestore(dir)
|
||||
const drive = new Hyperdrive(store)
|
||||
await drive.ready()
|
||||
await drive.put('/boot/init.js', b4a.from('async function start() {}'))
|
||||
const buf = await drive.get('/boot/init.js')
|
||||
t.ok(buf)
|
||||
t.is(b4a.toString(buf), 'async function start() {}')
|
||||
await drive.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import b4a from 'b4a'
|
||||
import crypto from 'hypercore-crypto'
|
||||
|
||||
export const PROTOCOL_NAME = 'bare-os-v1'
|
||||
export const TOPIC_STRING = 'bare-os-v1'
|
||||
export const BLOCK_SIZE = 512
|
||||
export const MBR_MAGIC = b4a.from('BIOS')
|
||||
|
||||
export function topicKey(b4aMod = b4a) {
|
||||
return crypto.hash(b4aMod.from(TOPIC_STRING))
|
||||
}
|
||||
|
||||
export function buildMbr(primaryKey, failoverKeys = []) {
|
||||
if (primaryKey.byteLength !== 32) {
|
||||
throw new Error('primaryKey must be 32 bytes')
|
||||
}
|
||||
const mbr = b4a.alloc(BLOCK_SIZE)
|
||||
mbr.set(MBR_MAGIC)
|
||||
mbr.set(primaryKey, 8)
|
||||
const slots = [40, 72]
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const k = failoverKeys[i]
|
||||
if (k) {
|
||||
if (k.byteLength !== 32) throw new Error('failover key must be 32 bytes')
|
||||
mbr.set(k, slots[i])
|
||||
}
|
||||
}
|
||||
return mbr
|
||||
}
|
||||
|
||||
export function parseMbr(mbr) {
|
||||
if (mbr.byteLength < 104) throw new Error('MBR too short')
|
||||
if (b4a.toString(mbr.slice(0, 4)) !== 'BIOS') {
|
||||
throw new Error('Invalid MBR magic')
|
||||
}
|
||||
const keys = [mbr.slice(8, 40), mbr.slice(40, 72), mbr.slice(72, 104)].filter(
|
||||
(k) => !b4a.equals(k, b4a.alloc(32))
|
||||
)
|
||||
return { keys }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
PROTOCOL_NAME,
|
||||
TOPIC_STRING,
|
||||
BLOCK_SIZE,
|
||||
MBR_MAGIC,
|
||||
topicKey,
|
||||
buildMbr,
|
||||
parseMbr
|
||||
} from './constants.js'
|
||||
|
||||
export { setupSeedChannel } from './lib/channel.js'
|
||||
@@ -0,0 +1,68 @@
|
||||
import b4a from 'b4a'
|
||||
import c from 'compact-encoding'
|
||||
import { PROTOCOL_NAME } from '../constants.js'
|
||||
import {
|
||||
msgDataEncoding,
|
||||
msgSearchReqEncoding,
|
||||
msgSearchResEncoding,
|
||||
msgRpcReqEncoding,
|
||||
msgRpcResEncoding
|
||||
} from './messages.js'
|
||||
|
||||
/**
|
||||
* Seeder-side Protomux channel: MBR block service + stub search/RPC + drive replication.
|
||||
* @param {import('protomux')} mux
|
||||
* @param {Map<number, Uint8Array>} localRAM
|
||||
* @param {(stream: any) => void} replicateDrive - e.g. (s) => drive.replicate(s)
|
||||
*/
|
||||
export function setupSeedChannel(mux, localRAM, replicateDrive) {
|
||||
const chan = mux.createChannel({ protocol: PROTOCOL_NAME })
|
||||
|
||||
chan.addMessage({
|
||||
encoding: c.uint32,
|
||||
onmessage(index) {
|
||||
const data = localRAM.get(index)
|
||||
if (data) chan.messages[1].send({ index, data })
|
||||
}
|
||||
})
|
||||
|
||||
chan.addMessage({
|
||||
encoding: msgDataEncoding
|
||||
})
|
||||
|
||||
chan.addMessage({ encoding: c.buffer })
|
||||
|
||||
chan.addMessage({
|
||||
encoding: msgSearchReqEncoding,
|
||||
onmessage(m) {
|
||||
chan.messages[4].send({ id: m.id, matches: [] })
|
||||
}
|
||||
})
|
||||
|
||||
chan.addMessage({
|
||||
encoding: msgSearchResEncoding
|
||||
})
|
||||
|
||||
chan.addMessage({
|
||||
encoding: msgRpcReqEncoding,
|
||||
onmessage(m) {
|
||||
chan.messages[6].send({
|
||||
id: m.id,
|
||||
success: false,
|
||||
result: '',
|
||||
error: 'RPC not implemented on seeder'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
chan.addMessage({
|
||||
encoding: msgRpcResEncoding
|
||||
})
|
||||
|
||||
chan.open()
|
||||
const bitfield = b4a.alloc(250)
|
||||
bitfield[0] |= 1
|
||||
chan.messages[2].send(bitfield)
|
||||
|
||||
replicateDrive(mux.stream)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import c from 'compact-encoding'
|
||||
|
||||
/** Read request: block index */
|
||||
export const msgRead = {
|
||||
encoding: c.uint32,
|
||||
onmessage: null
|
||||
}
|
||||
|
||||
/** Read response: index + data */
|
||||
export const msgDataEncoding = {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.index)
|
||||
c.buffer.preencode(state, m.data)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.index)
|
||||
c.buffer.encode(state, m.data)
|
||||
},
|
||||
decode(state) {
|
||||
return { index: c.uint32.decode(state), data: c.buffer.decode(state) }
|
||||
}
|
||||
}
|
||||
|
||||
export const msgSearchReqEncoding = {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.query)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.query)
|
||||
},
|
||||
decode(state) {
|
||||
return { id: c.uint32.decode(state), query: c.string.decode(state) }
|
||||
}
|
||||
}
|
||||
|
||||
export const msgSearchResEncoding = {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.array(c.string).preencode(state, m.matches)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.array(c.string).encode(state, m.matches)
|
||||
},
|
||||
decode(state) {
|
||||
return { id: c.uint32.decode(state), matches: c.array(c.string).decode(state) }
|
||||
}
|
||||
}
|
||||
|
||||
export const msgRpcReqEncoding = {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.string.preencode(state, m.module)
|
||||
c.string.preencode(state, m.method)
|
||||
c.array(c.string).preencode(state, m.args)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.string.encode(state, m.module)
|
||||
c.string.encode(state, m.method)
|
||||
c.array(c.string).encode(state, m.args)
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
module: c.string.decode(state),
|
||||
method: c.string.decode(state),
|
||||
args: c.array(c.string).decode(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const msgRpcResEncoding = {
|
||||
preencode(state, m) {
|
||||
c.uint32.preencode(state, m.id)
|
||||
c.bool.preencode(state, m.success)
|
||||
c.string.preencode(state, m.result)
|
||||
c.string.preencode(state, m.error)
|
||||
},
|
||||
encode(state, m) {
|
||||
c.uint32.encode(state, m.id)
|
||||
c.bool.encode(state, m.success)
|
||||
c.string.encode(state, m.result || '')
|
||||
c.string.encode(state, m.error || '')
|
||||
},
|
||||
decode(state) {
|
||||
return {
|
||||
id: c.uint32.decode(state),
|
||||
success: c.bool.decode(state),
|
||||
result: c.string.decode(state),
|
||||
error: c.string.decode(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "bare-os-protocol",
|
||||
"version": "0.1.0",
|
||||
"description": "MBR layout, swarm topic, and Protomux encodings for bare-operating-system",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./constants.js": "./constants.js",
|
||||
"./messages": "./lib/messages.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "brittle-bare test.js",
|
||||
"test:node": "brittle-node test.js"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.7",
|
||||
"compact-encoding": "^2.18.0",
|
||||
"hypercore-crypto": "^3.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"brittle": "^3.1.0"
|
||||
},
|
||||
"imports": {
|
||||
"fs": {
|
||||
"bare": "bare-fs",
|
||||
"default": "fs"
|
||||
},
|
||||
"path": {
|
||||
"bare": "bare-path",
|
||||
"default": "path"
|
||||
},
|
||||
"events": {
|
||||
"bare": "bare-events",
|
||||
"default": "events"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'brittle'
|
||||
import b4a from 'b4a'
|
||||
import crypto from 'hypercore-crypto'
|
||||
import {
|
||||
topicKey,
|
||||
buildMbr,
|
||||
parseMbr,
|
||||
BLOCK_SIZE,
|
||||
TOPIC_STRING
|
||||
} from './constants.js'
|
||||
import { msgDataEncoding, msgSearchReqEncoding } from './lib/messages.js'
|
||||
test('topicKey is deterministic 32-byte hash', (t) => {
|
||||
const a = topicKey()
|
||||
const b = topicKey()
|
||||
t.is(a.byteLength, 32)
|
||||
t.alike(a, b)
|
||||
})
|
||||
|
||||
test('topicKey differs from other string', (t) => {
|
||||
const k1 = topicKey()
|
||||
const k2 = crypto.hash(b4a.from('other-topic'))
|
||||
t.unlike(k1, k2)
|
||||
})
|
||||
|
||||
test('buildMbr + parseMbr roundtrip', (t) => {
|
||||
const key = crypto.hash(b4a.from('primary-drive'))
|
||||
const mbr = buildMbr(key)
|
||||
t.is(mbr.byteLength, BLOCK_SIZE)
|
||||
const { keys } = parseMbr(mbr)
|
||||
t.is(keys.length, 1)
|
||||
t.alike(keys[0], key)
|
||||
})
|
||||
|
||||
test('msgDataEncoding roundtrip', (t) => {
|
||||
const m = { index: 0, data: b4a.from('hello') }
|
||||
const pre = { buffer: null, start: 0, end: 0 }
|
||||
msgDataEncoding.preencode(pre, m)
|
||||
const buf = b4a.alloc(pre.end)
|
||||
const state = { buffer: buf, start: 0, end: 0 }
|
||||
msgDataEncoding.encode(state, m)
|
||||
const decState = { buffer: buf, start: 0, end: buf.byteLength }
|
||||
const dec = msgDataEncoding.decode(decState)
|
||||
t.is(dec.index, 0)
|
||||
t.alike(dec.data, m.data)
|
||||
})
|
||||
|
||||
test('msgSearchReqEncoding roundtrip', (t) => {
|
||||
const m = { id: 7, query: 'foo' }
|
||||
const pre = { buffer: null, start: 0, end: 0 }
|
||||
msgSearchReqEncoding.preencode(pre, m)
|
||||
const buf = b4a.alloc(pre.end)
|
||||
const state = { buffer: buf, start: 0, end: 0 }
|
||||
msgSearchReqEncoding.encode(state, m)
|
||||
const decState = { buffer: buf, start: 0, end: buf.byteLength }
|
||||
const dec = msgSearchReqEncoding.decode(decState)
|
||||
t.is(dec.id, 7)
|
||||
t.is(dec.query, 'foo')
|
||||
})
|
||||
|
||||
test('TOPIC_STRING is bare-os-v1', (t) => {
|
||||
t.is(TOPIC_STRING, 'bare-os-v1')
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import Hyperswarm from 'hyperswarm'
|
||||
import Protomux from 'protomux'
|
||||
import b4a from 'b4a'
|
||||
import safetyCatch from 'safety-catch'
|
||||
import Hyperdrive from 'hyperdrive'
|
||||
import Corestore from 'corestore'
|
||||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import {
|
||||
topicKey,
|
||||
buildMbr,
|
||||
setupSeedChannel,
|
||||
BLOCK_SIZE
|
||||
} from 'bare-os-protocol'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.join(__dirname, '..', '..')
|
||||
|
||||
function corestorePath() {
|
||||
return process.env.BARE_OS_SEED_STORE || path.join(repoRoot, 'data', 'corestore-seeder')
|
||||
}
|
||||
|
||||
/** @param {import('hyperdrive').default} drive */
|
||||
async function stageKernelTree(drive, kernelRoot) {
|
||||
async function walk(rel) {
|
||||
const abs = path.join(kernelRoot, rel)
|
||||
const entries = await readdir(abs, { withFileTypes: true })
|
||||
for (const ent of entries) {
|
||||
const name = ent.name
|
||||
const subRel = rel ? path.join(rel, name) : name
|
||||
const subAbs = path.join(kernelRoot, subRel)
|
||||
if (ent.isDirectory()) {
|
||||
await walk(subRel)
|
||||
} else {
|
||||
const raw = await readFile(subAbs)
|
||||
let drivePath
|
||||
if (subRel === 'init.js') {
|
||||
drivePath = '/boot/init.js'
|
||||
} else if (subRel.startsWith('bin' + path.sep)) {
|
||||
drivePath = '/bin/' + subRel.slice(4).split(path.sep).join('/')
|
||||
} else if (subRel.startsWith('etc' + path.sep)) {
|
||||
drivePath = '/etc/' + subRel.slice(4).split(path.sep).join('/')
|
||||
} else {
|
||||
drivePath = '/' + subRel.split(path.sep).join('/')
|
||||
}
|
||||
await drive.put(drivePath, b4a.from(raw))
|
||||
console.log(' [+] ' + drivePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const st = await stat(kernelRoot).catch(() => null)
|
||||
if (!st || !st.isDirectory()) {
|
||||
throw new Error('kernel directory missing: ' + kernelRoot)
|
||||
}
|
||||
console.log('Staging kernel from', kernelRoot)
|
||||
await walk('')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.clear?.()
|
||||
console.log('--- bare-os-seeder (Hyperdrive + MBR) ---')
|
||||
|
||||
const kernelRoot = process.env.BARE_OS_KERNEL_ROOT
|
||||
? path.resolve(process.env.BARE_OS_KERNEL_ROOT)
|
||||
: path.join(repoRoot, 'kernel')
|
||||
|
||||
const store = new Corestore(corestorePath())
|
||||
const drive = new Hyperdrive(store)
|
||||
await drive.ready()
|
||||
console.log('Drive ready:', drive.id)
|
||||
|
||||
await stageKernelTree(drive, kernelRoot)
|
||||
|
||||
const localRAM = new Map()
|
||||
const mbr = buildMbr(drive.key)
|
||||
if (mbr.byteLength !== BLOCK_SIZE) throw new Error('MBR size mismatch')
|
||||
localRAM.set(0, mbr)
|
||||
console.log(' [+] MBR block 0 (BIOS + primary key)')
|
||||
|
||||
const swarm = new Hyperswarm()
|
||||
const topic = topicKey()
|
||||
|
||||
swarm.on('connection', (socket) => {
|
||||
const mux = new Protomux(socket)
|
||||
setupSeedChannel(mux, localRAM, (stream) => {
|
||||
drive.replicate(stream)
|
||||
})
|
||||
})
|
||||
|
||||
swarm.join(topic)
|
||||
swarm.join(drive.discoveryKey)
|
||||
await swarm.flush()
|
||||
|
||||
console.log('\nSeeder active. Topic (discovery):', b4a.toString(topic, 'hex').slice(0, 16) + '...')
|
||||
console.log('Drive key:', b4a.toString(drive.key, 'hex'))
|
||||
console.log('(Kernel read directly into Hyperdrive.) Corestore:', corestorePath())
|
||||
}
|
||||
|
||||
main().catch(safetyCatch)
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "bare-os-seeder",
|
||||
"version": "0.1.0",
|
||||
"description": "Pear/Bare seeder: publishes system Hyperdrive and MBR over Hyperswarm",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"start": "bare index.js",
|
||||
"dev": "bare index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bare-os-protocol": "*",
|
||||
"b4a": "^1.6.7",
|
||||
"compact-encoding": "^2.18.0",
|
||||
"corestore": "^7.2.1",
|
||||
"hyperdrive": "^13.3.2",
|
||||
"hyperswarm": "^4.16.0",
|
||||
"protomux": "^3.10.1",
|
||||
"safety-catch": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=2.0.0"
|
||||
},
|
||||
"pear": {
|
||||
"name": "bare-os-seeder",
|
||||
"stage": {
|
||||
"ignore": [
|
||||
".git",
|
||||
"test",
|
||||
"coverage",
|
||||
".DS_Store",
|
||||
"node_modules/.bin",
|
||||
"node_modules/.package-lock.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"imports": {
|
||||
"fs": {
|
||||
"bare": "bare-fs",
|
||||
"default": "node:fs"
|
||||
},
|
||||
"fs/promises": {
|
||||
"bare": "bare-fs/promises",
|
||||
"default": "node:fs/promises"
|
||||
},
|
||||
"path": {
|
||||
"bare": "bare-path",
|
||||
"default": "node:path"
|
||||
},
|
||||
"url": {
|
||||
"bare": "bare-url",
|
||||
"default": "node:url"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user