Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,5 @@ dist-ssr
*.sw?

.opencode

.todo.md
openapi.json
81 changes: 81 additions & 0 deletions cli/broker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import cors from "cors"
import express from "express"

import { BROKER_HOST } from "./lib.js"

const port = parseInt(process.argv[2], 10)
if (!port) {
console.error("Broker: No port provided. Usage: broker.js <port>")
process.exit(1)
}

const STALE_MS = 30000
const app = express()
app.use(cors())
app.use(express.json())

// Registered instances: { cwd, port, host, status, lastSeen }
let instances = []

function updateInstanceStatus() {
const now = Date.now()
for (const inst of instances) {
if (inst.lastSeen && now - inst.lastSeen > STALE_MS) {
inst.status = "offline"
}
}
}

// Register instance
app.post("/register", (req, res) => {
const { cwd, port } = req.body
if (!cwd || !port) return res.status(400).send("Missing cwd or port")
let inst = instances.find((i) => i.cwd === cwd)
if (inst) {
inst.port = port
inst.lastSeen = Date.now()
inst.status = "online"
} else {
instances.push({
cwd,
port,
host: BROKER_HOST,
status: "online",
lastSeen: Date.now(),
})
}
res.sendStatus(200)
})

// Ping instance
app.post("/ping", (req, res) => {
const { cwd, port } = req.body
if (!cwd || !port) return res.status(400).send("Missing cwd or port")
let inst = instances.find((i) => i.cwd === cwd && i.port === port)
if (inst) {
inst.lastSeen = Date.now()
}
res.sendStatus(200)
})

// Deregister instance
app.post("/deregister", (req, res) => {
const { cwd } = req.body
if (!cwd) return res.status(400).send("Missing cwd")
let inst = instances.find((i) => i.cwd === cwd)
if (inst) {
inst.status = "offline"
}
res.json({ instances })
})

// List instances
app.get("/instances", (req, res) => {
updateInstanceStatus()
res.json({ version: "1.0.0", info: { name: "opencode-web" }, instances })
})

app.listen(port, BROKER_HOST, () => {
console.log(`Broker running at http://${BROKER_HOST}:${port}`)
})
194 changes: 194 additions & 0 deletions cli/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import path from "path"
import { fileURLToPath } from "url"
import cors from "cors"
import express from "express"
import getPort from "get-port"
import { createProxyMiddleware } from "http-proxy-middleware"

import { BROKER_HOST, BROKER_PORT_RANGE } from "./lib.js"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

async function isBrokerOnPort(port) {
try {
const res = await fetch(`http://${BROKER_HOST}:${port}/instances`)
if (!res.ok) return false
const data = await res.json()
return (
Array.isArray(data.instances) &&
data.info &&
data.info.name === "opencode-web"
)
} catch {
return false
}
}

function isPortFree(port) {
return new Promise((resolve) => {
const testServer = express().listen(port, BROKER_HOST, () => {
testServer.close(() => resolve(true))
})
testServer.on("error", () => resolve(false))
})
}

async function spawnDetachedBroker(port) {
const brokerPath = path.join(__dirname, "broker.js")
const proc = spawn(process.execPath, [brokerPath, port], {
detached: true,
stdio: "ignore",
})
proc.unref()
// Wait for broker to be ready
for (let i = 0; i < 20; i++) {
await delay(200)
if (await isBrokerOnPort(port)) {
return port
}
}
throw new Error(`Failed to start broker on port ${port}`)
}

async function findOrStartBroker() {
// Try to find an existing broker
for (const port of BROKER_PORT_RANGE) {
if (await isBrokerOnPort(port)) {
return port
}
}
console.log("No existing broker found, trying to start one...")
// Try to start a broker on a free port
for (const port of BROKER_PORT_RANGE) {
if (await isPortFree(port)) {
return await spawnDetachedBroker(port)
}
}
throw new Error("Could not start or find a broker in the allowed port range.")
}

async function registerInstance(brokerPort, proxyPort, cwd) {
try {
await fetch(`http://${BROKER_HOST}:${brokerPort}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd, port: proxyPort }),
})
} catch (e) {
console.error("Failed to register with broker:", e)
}
}

async function pingInstance(brokerPort, proxyPort, cwd) {
try {
await fetch(`http://${BROKER_HOST}:${brokerPort}/ping`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd, port: proxyPort }),
})
} catch (e) {
// ignore
}
}

async function deregisterInstance(brokerPort, cwd) {
try {
await fetch(`http://${BROKER_HOST}:${brokerPort}/deregister`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd }),
})
} catch (e) {
// ignore
}
}

async function startProxyAndOpencode() {
const PROXY_PORT = await getPort({ port: 15096 })
const OPENCODE_PORT = await getPort({ port: 11923 })
const cwd = process.cwd()

// Start opencode server
console.log(`Starting opencode server on port ${OPENCODE_PORT}...`)
const opencodeProc = spawn("opencode", ["serve", "--port", OPENCODE_PORT], {
stdio: "inherit",
shell: true,
cwd: cwd,
})

opencodeProc.on("error", (err) => {
console.error("Failed to start opencode server:", err)
process.exit(1)
})

opencodeProc.on("exit", (code, signal) => {
if (code !== 0) {
console.error(
`opencode server exited with code ${code} (signal: ${signal})`
)
process.exit(code || 1)
}
})

const app = express()
app.use(cors())

// Proxy /api to opencode server
app.use(
"/api",
createProxyMiddleware({
target: `http://localhost:${OPENCODE_PORT}`,
changeOrigin: true,
ws: true,
onProxyRes: (proxyRes, req, res) => {
proxyRes.headers["Access-Control-Allow-Origin"] = "*"
proxyRes.headers["Access-Control-Allow-Headers"] = "*"
proxyRes.headers["Access-Control-Allow-Methods"] =
"GET,POST,PUT,DELETE,OPTIONS"
},
})
)

app.listen(PROXY_PORT, "127.0.0.1", () => {
console.log(`Local proxy listening at http://localhost:${PROXY_PORT}`)
console.log(`Proxying /api requests to http://localhost:${OPENCODE_PORT}`)
console.log(`Open your web client: https://opencode-web.vercel.app`)
})

return { PROXY_PORT }
}

// MAIN
;(async () => {
// 1. Ensure broker is running
const brokerPort = await findOrStartBroker()
console.log(`Broker running on port ${brokerPort}`)

// 2. Start proxy and opencode
const { PROXY_PORT } = await startProxyAndOpencode()
const cwd = process.cwd()

// 3. Register with broker
await registerInstance(brokerPort, PROXY_PORT, cwd)
// Heartbeat every 10s
const regInterval = setInterval(
() => pingInstance(brokerPort, PROXY_PORT, cwd),
10000
)

// 4. Deregister on exit
const cleanup = async () => {
clearInterval(regInterval)
await deregisterInstance(brokerPort, cwd)
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
})()
2 changes: 2 additions & 0 deletions cli/lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const BROKER_PORT_RANGE = [13943, 14839, 18503, 19304, 20197]
export const BROKER_HOST = "127.0.0.1"
20 changes: 20 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "proxy-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"get-port": "^7.1.0",
"http-proxy-middleware": "^3.0.5",
"yargs": "^18.0.0"
}
}
Loading