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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ dist-ssr

.opencode
.todo.md
openapi.json
.execute.md
.prd.md
openapi.json
4 changes: 4 additions & 0 deletions apps/cli/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import cliConfig from "@workspace/eslint-config/cli"

/** @type {import("eslint").Linter.Config} */
export default cliConfig
14 changes: 13 additions & 1 deletion cli/package.json → apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
{
"name": "proxy-server",
"name": "opencode-web-cli",
"version": "1.0.0",
"description": "",
"bin": {
"opencode-web": "src/index.ts"
},
"main": "index.js",
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx src/index.ts",
"lint": "eslint .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
Expand All @@ -16,5 +22,11 @@
"get-port": "^7.1.0",
"http-proxy-middleware": "^3.0.5",
"yargs": "^18.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/yargs": "^17.0.24",
"tsx": "^4.20.3"
}
}
File renamed without changes.
30 changes: 30 additions & 0 deletions apps/cli/src/broker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env tsx
import cors from "cors"
import express from "express"

import { BROKER_HOST } from "../lib.js"
import type { Instance } from "../types"
import createProcRoutes from "./routes/proc-routes.js"
import createPublicRoutes from "./routes/public-routes.js"

// ESM __dirname shim

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

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

let instances: Instance[] = []

// mount route modules
app.use("/", createProcRoutes(instances))
app.use("/", createPublicRoutes(instances))

app.listen(port, BROKER_HOST, () => {
console.log(`Broker running at http://${BROKER_HOST}:${port}`)
})
57 changes: 57 additions & 0 deletions apps/cli/src/broker/routes/proc-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Router } from "express"

import { BROKER_HOST } from "../../lib"
import type { Instance } from "../../types"

export default function createProcRoutes(instances: Instance[]) {
const router = Router()

router.post("/register", (req, res) => {
const { cwd, port: instancePort } = req.body as {
cwd?: string
port?: number
}
if (!cwd || !instancePort)
return res.status(400).send("Missing cwd or port")
let inst = instances.find((i) => i.cwd === cwd)
// in case port changes for a cwd
if (inst) {
inst.port = instancePort
inst.lastSeen = Date.now()
inst.status = "online"
inst.startedAt = Date.now()
} else {
instances.push({
cwd,
port: instancePort,
host: BROKER_HOST,
status: "online",
lastSeen: Date.now(),
startedAt: Date.now(),
})
}
res.sendStatus(200)
})

router.post("/ping", (req, res) => {
const { cwd, port: instancePort } = req.body as {
cwd?: string
port?: number
}
if (!cwd || !instancePort)
return res.status(400).send("Missing cwd or port")
const inst = instances.find((i) => i.cwd === cwd && i.port === instancePort)
if (inst) inst.lastSeen = Date.now()
res.sendStatus(200)
})

router.post("/deregister", (req, res) => {
const { cwd } = req.body as { cwd?: string }
if (!cwd) return res.status(400).send("Missing cwd")
const inst = instances.find((i) => i.cwd === cwd)
if (inst) inst.status = "offline"
res.json({ instances })
})

return router
}
109 changes: 109 additions & 0 deletions apps/cli/src/broker/routes/public-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { spawn } from "child_process"
import path from "path"
import { fileURLToPath } from "url"
import { Router } from "express"

import type { Instance } from "../../types"

const __filename = fileURLToPath(import.meta.url)
const ROUTES_DIR = path.dirname(__filename)
const BROKER_DIR = path.dirname(ROUTES_DIR) // up one level to broker folder

export default function createPublicRoutes(instances: Instance[]) {
const router = Router()

// helper to mark stale
const INSTANCE_STALE_TIMEOUT_MS = 30000
function updateInstanceStatus() {
const now = Date.now()
for (const inst of instances) {
if (inst.lastSeen && now - inst.lastSeen > INSTANCE_STALE_TIMEOUT_MS) {
inst.status = "offline"
}
}
}

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

// Helper to wait for registration/offline
function waitFor(cond: () => boolean, timeoutMs = 10000): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now()
const timer = setInterval(() => {
if (cond()) {
clearInterval(timer)
resolve(true)
} else if (Date.now() - start > timeoutMs) {
clearInterval(timer)
resolve(false)
}
}, 200)
})
}

// POST /instance -> start
router.post("/instance", async (req, res) => {
const { cwd } = req.body as { cwd?: string }
if (!cwd) return res.status(400).send("Missing cwd")

const existing = instances.find(
(i) => i.cwd === cwd && i.status === "online"
)
if (existing)
return res.status(409).json({ message: "Instance already running" })

const daemonPath = path.join(BROKER_DIR, "..", "opencode-proc", "index.ts")
const proc = spawn("tsx", [daemonPath, cwd], {
cwd,
detached: false,
stdio: "ignore",
shell: true,
})

const ok = await waitFor(() =>
Boolean(instances.find((i) => i.cwd === cwd && i.status === "online"))
)

if (ok) {
proc.unref()
const inst = instances.find(
(i) => i.cwd === cwd && i.status === "online"
)!
return res
.status(201)
.json({ message: "Instance ready", port: inst.port })
}

try {
proc.kill()
} catch {}
return res.status(500).json({ error: "startup timeout" })
})

// DELETE /instance -> stop
router.delete("/instance", async (req, res) => {
const { cwd } = req.body as { cwd?: string }
if (!cwd) return res.status(400).send("Missing cwd")

const inst = instances.find((i) => i.cwd === cwd && i.status === "online")
if (!inst) return res.status(404).json({ message: "Instance not found" })

try {
await fetch(`http://localhost:${inst.port}/__shutdown`, {
method: "POST",
})
} catch {}

const ok = await waitFor(
() => !instances.find((i) => i.cwd === cwd && i.status === "online")
)

if (ok) return res.json({ message: "Instance stopped" })
return res.status(500).json({ error: "shutdown timeout" })
})

return router
}
Loading