#!/usr/bin/env node
/**
* Runtime smoke for a launched PearBrowser process.
*
* Start PearBrowser first, then run:
*
* node scripts/runtime-rpc-smoke.mjs
*
* The app exposes a diagnostic WebSocket path at /status-smoke that mirrors the
* normal renderer RPC stream without becoming the renderer or triggering app
* shutdown. This script asks CMD_GET_STATUS until the backend is ready.
*/
import { EventEmitter } from 'node:events'
import net from 'node:net'
import { randomBytes } from 'node:crypto'
const CMD_GET_STATUS = 2
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_PORT_BASE = 9876
const DEFAULT_PORT_COUNT = 5
const DEFAULT_TIMEOUT_MS = 15_000
const DEFAULT_CONNECT_MS = 1_500
const DEFAULT_REQUEST_MS = 3_000
const DEFAULT_MAX_STORAGE_PERCENT = Infinity
function parseArgs (argv) {
const args = {
host: DEFAULT_HOST,
portBase: DEFAULT_PORT_BASE,
portCount: DEFAULT_PORT_COUNT,
timeout: DEFAULT_TIMEOUT_MS,
connectTimeout: DEFAULT_CONNECT_MS,
requestTimeout: DEFAULT_REQUEST_MS,
minHiveRelays: 1,
maxStoragePercent: DEFAULT_MAX_STORAGE_PERCENT,
json: false
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--host') args.host = argv[++i] || args.host
else if (arg === '--port-base') args.portBase = parsePositiveInt(argv[++i], '--port-base')
else if (arg === '--port-count') args.portCount = parsePositiveInt(argv[++i], '--port-count')
else if (arg === '--timeout') args.timeout = parseDuration(argv[++i], '--timeout')
else if (arg === '--connect-timeout') args.connectTimeout = parseDuration(argv[++i], '--connect-timeout')
else if (arg === '--request-timeout') args.requestTimeout = parseDuration(argv[++i], '--request-timeout')
else if (arg === '--min-hive-relays') args.minHiveRelays = parseNonNegativeInt(argv[++i], '--min-hive-relays')
else if (arg === '--max-storage-percent') args.maxStoragePercent = parseNonNegativeNumber(argv[++i], '--max-storage-percent')
else if (arg === '--json') args.json = true
else if (arg === '-h' || arg === '--help') usage(0)
else usage(2, `unknown option: ${arg}`)
}
return args
}
function usage (code, msg = '') {
if (msg) console.error('error:', msg)
console.error('usage: node scripts/runtime-rpc-smoke.mjs [--timeout 15000] [--port-base 9876] [--port-count 5] [--min-hive-relays 1] [--max-storage-percent 100] [--json]')
process.exit(code)
}
function parseDuration (value, label) {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) usage(2, `${label} must be a positive number of milliseconds`)
return Math.floor(n)
}
function parsePositiveInt (value, label) {
const n = Number(value)
if (!Number.isInteger(n) || n <= 0) usage(2, `${label} must be a positive integer`)
return n
}
function parseNonNegativeInt (value, label) {
const n = Number(value)
if (!Number.isInteger(n) || n < 0) usage(2, `${label} must be a non-negative integer`)
return n
}
function parseNonNegativeNumber (value, label) {
const n = Number(value)
if (!Number.isFinite(n) || n < 0) usage(2, `${label} must be a non-negative number`)
return n
}
function frame (msg) {
const json = JSON.stringify(msg)
return json.length.toString(16).padStart(8, '0') + json
}
function parseFrames (state, data) {
state.buffer += Buffer.isBuffer(data) ? data.toString('utf8') : String(data)
const out = []
while (state.buffer.length >= 8) {
const len = parseInt(state.buffer.slice(0, 8), 16)
if (!Number.isFinite(len) || len <= 0 || len > 10_000_000) {
throw new Error(`invalid RPC frame length: ${state.buffer.slice(0, 8)}`)
}
if (state.buffer.length < 8 + len) break
const json = state.buffer.slice(8, 8 + len)
state.buffer = state.buffer.slice(8 + len)
out.push(JSON.parse(json))
}
return out
}
class RawWebSocket extends EventEmitter {
constructor (socket, initial = Buffer.alloc(0)) {
super()
this.socket = socket
this.buffer = initial
this.closed = false
socket.on('data', (chunk) => this._onData(chunk))
socket.on('close', () => {
if (this.closed) return
this.closed = true
this.emit('close')
})
socket.on('error', (err) => this.emit('error', err))
if (initial.length) queueMicrotask(() => this._drain())
}
send (text) {
if (this.closed) return
const payload = Buffer.from(String(text))
const mask = randomBytes(4)
let header
if (payload.length < 126) {
header = Buffer.alloc(2)
header[0] = 0x81
header[1] = 0x80 | payload.length
} else if (payload.length <= 0xffff) {
header = Buffer.alloc(4)
header[0] = 0x81
header[1] = 0x80 | 126
header.writeUInt16BE(payload.length, 2)
} else {
header = Buffer.alloc(10)
header[0] = 0x81
header[1] = 0x80 | 127
header.writeBigUInt64BE(BigInt(payload.length), 2)
}
const masked = Buffer.alloc(payload.length)
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4]
this.socket.write(Buffer.concat([header, mask, masked]))
}
close () {
this.closed = true
try { this.socket.end() } catch {}
}
_onData (chunk) {
this.buffer = Buffer.concat([this.buffer, chunk])
this._drain()
}
_drain () {
while (this.buffer.length >= 2) {
const b0 = this.buffer[0]
const b1 = this.buffer[1]
const opcode = b0 & 0x0f
const masked = (b1 & 0x80) !== 0
let len = b1 & 0x7f
let offset = 2
if (len === 126) {
if (this.buffer.length < offset + 2) return
len = this.buffer.readUInt16BE(offset)
offset += 2
} else if (len === 127) {
if (this.buffer.length < offset + 8) return
len = Number(this.buffer.readBigUInt64BE(offset))
offset += 8
}
const maskOffset = offset
if (masked) offset += 4
if (this.buffer.length < offset + len) return
let payload = this.buffer.subarray(offset, offset + len)
if (masked) {
const mask = this.buffer.subarray(maskOffset, maskOffset + 4)
const unmasked = Buffer.alloc(payload.length)
for (let i = 0; i < payload.length; i++) unmasked[i] = payload[i] ^ mask[i % 4]
payload = unmasked
}
this.buffer = this.buffer.subarray(offset + len)
if (opcode === 0x8) {
this.close()
this.emit('close')
return
}
if (opcode === 0x1 || opcode === 0x2) this.emit('message', payload)
}
}
}
async function connect (url, timeout) {
return await new Promise((resolve, reject) => {
const u = new URL(url)
const socket = net.createConnection({ host: u.hostname, port: Number(u.port) })
const key = randomBytes(16).toString('base64')
let settled = false
let buffer = Buffer.alloc(0)
const timer = setTimeout(() => done(new Error(`connect timeout: ${url}`)), timeout)
function done (err, ws = null) {
if (settled) return
settled = true
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
if (err) {
try { socket.destroy() } catch {}
reject(err)
} else {
resolve(ws)
}
}
function onConnect () {
const path = (u.pathname || '/') + (u.search || '')
socket.write([
`GET ${path} HTTP/1.1`,
`Host: ${u.hostname}:${u.port}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${key}`,
'Sec-WebSocket-Version: 13',
'',
''
].join('\r\n'))
}
function onData (chunk) {
buffer = Buffer.concat([buffer, chunk])
const end = buffer.indexOf('\r\n\r\n')
if (end === -1) return
const head = buffer.subarray(0, end).toString('latin1')
const rest = buffer.subarray(end + 4)
if (!/^HTTP\/1\.1 101\b/i.test(head)) {
done(new Error(`websocket handshake rejected: ${head.split('\r\n')[0] || 'no status'}`))
return
}
done(null, new RawWebSocket(socket, rest))
}
function onError () { done(new Error(`connect failed: ${url}`)) }
function onClose () { done(new Error(`socket closed before handshake: ${url}`)) }
socket.once('connect', onConnect)
socket.on('data', onData)
socket.once('error', onError)
socket.once('close', onClose)
})
}
async function requestStatus (ws, timeout) {
const id = 900_000_000 + Math.floor(Math.random() * 10_000_000)
const state = { buffer: '' }
return await new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => done(new Error(`RPC timeout: cmd ${CMD_GET_STATUS}`)), timeout)
function done (err, value) {
if (settled) return
settled = true
clearTimeout(timer)
ws.off('message', onMessage)
ws.off('close', onClose)
ws.off('error', onError)
err ? reject(err) : resolve(value)
}
function onClose () {
done(new Error('diagnostic socket closed before status reply'))
}
function onError () {
done(new Error('diagnostic socket error before status reply'))
}
function onMessage (data) {
let messages
try {
messages = parseFrames(state, data)
} catch (err) {
done(err)
return
}
for (const msg of messages) {
if (msg?.event === 'backend-boot-failed') {
done(new Error(`backend boot failed: ${msg.data?.message || 'unknown error'}`))
return
}
if (msg?.id !== id) continue
if (msg.error) done(new Error(msg.error))
else done(null, msg.result)
return
}
}
ws.on('message', onMessage)
ws.on('close', onClose)
ws.on('error', onError)
ws.send(frame({ id, cmd: CMD_GET_STATUS, data: {} }))
})
}
function validateStatus (status, args) {
const errors = []
if (!status || typeof status !== 'object') {
return ['status reply is not an object']
}
if (status.dhtConnected !== true) errors.push('DHT is not connected')
if (!Number.isInteger(status.proxyPort) || status.proxyPort <= 0) errors.push('HTTP proxy is not ready')
if (!Number.isInteger(status.peerCount) || status.peerCount < 0) errors.push('peerCount is invalid')
if (!Number.isInteger(status.hiveRelays) || status.hiveRelays < args.minHiveRelays) {
errors.push(`hiveRelays is below ${args.minHiveRelays}`)
}
if (!Number.isFinite(status.storageUsed) || status.storageUsed < 0) errors.push('storageUsed is invalid')
if (!Number.isFinite(status.storageLimit) || status.storageLimit <= 0) errors.push('storageLimit is invalid')
if (!Number.isFinite(status.storagePercent) || status.storagePercent < 0) errors.push('storagePercent is invalid')
if (Number.isFinite(args.maxStoragePercent) && Number.isFinite(status.storagePercent) && status.storagePercent > args.maxStoragePercent) {
errors.push(`storagePercent exceeds ${args.maxStoragePercent}`)
}
return errors
}
function sleep (ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function probeOnce (args, deadline) {
let last = null
for (let i = 0; i < args.portCount; i++) {
const port = args.portBase + i
const remain = Math.max(1, deadline - Date.now())
const connectTimeout = Math.min(args.connectTimeout, remain)
const requestTimeout = Math.min(args.requestTimeout, remain)
const url = `ws://${args.host}:${port}/status-smoke`
let ws = null
try {
ws = await connect(url, connectTimeout)
const status = await requestStatus(ws, requestTimeout)
const errors = validateStatus(status, args)
return { port, status, errors, url }
} catch (err) {
last = { port, url, error: err.message }
} finally {
try { ws?.close() } catch {}
}
}
return { errors: ['no diagnostic RPC socket answered'], last }
}
async function main () {
const args = parseArgs(process.argv.slice(2))
const deadline = Date.now() + args.timeout
let last = null
while (Date.now() < deadline) {
const result = await probeOnce(args, deadline)
last = result
if (result.status && result.errors.length === 0) {
const output = { ok: true, port: result.port, status: result.status }
if (args.json) console.log(JSON.stringify(output))
else {
console.log('Runtime RPC smoke passed')
console.log(` rpcPort: ${result.port}`)
console.log(` proxyPort: ${result.status.proxyPort}`)
console.log(` dhtConnected: ${result.status.dhtConnected}`)
console.log(` peerCount: ${result.status.peerCount}`)
console.log(` hiveRelays: ${result.status.hiveRelays}`)
console.log(` storageUsed: ${result.status.storageUsed}`)
}
return
}
await sleep(500)
}
const output = { ok: false, last }
if (args.json) console.log(JSON.stringify(output))
else {
console.error('Runtime RPC smoke failed')
if (last?.status) {
console.error(' last status:', JSON.stringify(last.status))
console.error(' errors:', last.errors.join('; '))
} else if (last?.last) {
console.error(` last probe ${last.last.url}: ${last.last.error}`)
} else {
console.error(' no diagnostic RPC socket answered')
}
}
process.exit(1)
}
main().catch((err) => {
console.error(err.stack || err.message)
process.exit(1)
})