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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ bun.lockb
# npm auth (created by CI)
.npmrc
.swarm/
opensrc/
25 changes: 21 additions & 4 deletions apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server"
import { type NextRequest, NextResponse } from "next/server"

/**
* API Proxy for OpenCode servers
Expand Down Expand Up @@ -31,6 +31,8 @@ import { NextRequest, NextResponse } from "next/server"
* // Returns response to browser (same-origin)
*/

import { createAuthorizationHeader, getManualServerByProxyPort } from "@/lib/manual-server-registry"

type RouteContext = {
params: Promise<{
port: string
Expand Down Expand Up @@ -82,9 +84,13 @@ function validatePort(
* buildTargetUrl(4056, ['session', 'list'])
* // => 'http://127.0.0.1:4056/session/list'
*/
function buildTargetUrl(port: number, path: string[] = []): string {
function buildTargetUrl(base: string | number, path: string[] = []): string {
const pathString = path.length > 0 ? `/${path.join("/")}` : ""
return `http://127.0.0.1:${port}${pathString}`
const baseString = String(base)
if (baseString.startsWith("http")) {
return `${baseString}${pathString}`
}
return `http://127.0.0.1:${baseString}${pathString}`
}

/**
Expand All @@ -100,7 +106,10 @@ async function proxyRequest(
port: number,
path: string[] = [],
): Promise<NextResponse> {
const targetUrl = buildTargetUrl(port, path)
const manualServer = await getManualServerByProxyPort(port)
const targetUrl = manualServer
? buildTargetUrl(manualServer.url, path)
: buildTargetUrl(port, path)

try {
// Copy headers from incoming request
Expand All @@ -118,6 +127,14 @@ async function proxyRequest(
headers.set("content-type", contentType)
}

// Add auth for manual (remote) servers
if (manualServer) {
const authorization = createAuthorizationHeader(manualServer)
if (authorization) {
headers.set("authorization", authorization)
}
}

// Copy body for POST/PUT/PATCH
let body: ReadableStream | null = null
if (["POST", "PUT", "PATCH"].includes(request.method)) {
Expand Down
177 changes: 139 additions & 38 deletions apps/web/src/app/api/opencode/servers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@
*/

import { exec } from "child_process"
import { promisify } from "util"
import { NextResponse } from "next/server"
import { promisify } from "util"
import {
addServer,
type ManualServer,
readRegistry,
removeServer,
verifyManualServer,
} from "@/lib/manual-server-registry"

const execAsync = promisify(exec)

Expand All @@ -27,6 +34,10 @@ interface DiscoveredServer {
pid: number
directory: string
sessions?: string[] // Session IDs hosted by this server
source: "local" | "manual"
url?: string
name?: string
proxyPort?: number
}

interface CandidatePort {
Expand Down Expand Up @@ -83,13 +94,39 @@ async function verifyOpencodeServer(candidate: CandidatePort): Promise<Discovere
pid: candidate.pid,
directory,
sessions,
source: "local",
}
} catch {
clearTimeout(timeoutId)
return null
}
}

async function verifyAndTransformManualServers(
manualServers: ManualServer[],
): Promise<DiscoveredServer[]> {
const results = await Promise.all(
manualServers.map(async (server) => {
const verified = await verifyManualServer(server, 2000)
if (!verified) return null

const resolvedServer: DiscoveredServer = {
port: verified.proxyPort,
pid: 0,
directory: verified.directory,
sessions: verified.sessions,
source: "manual" as const,
url: server.url,
name: server.name,
proxyPort: verified.proxyPort,
}
return resolvedServer
}),
)

return results.filter((s) => s !== null)
}

/**
* Run promises with limited concurrency
*/
Expand Down Expand Up @@ -119,46 +156,16 @@ export async function GET() {
const startTime = Date.now()

try {
// Find all listening TCP ports for bun/opencode processes
const { stdout } = await execAsync(
`lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E 'bun|opencode' | awk '{print $2, $9}'`,
{ timeout: 2000 },
).catch((error) => {
// lsof returns exit code 1 when grep finds no matches - that's OK
if (error.stdout !== undefined) {
return { stdout: error.stdout || "" }
}
console.error("[opencode/servers] lsof failed:", error.message)
throw error
})

// Parse candidates
const candidates: CandidatePort[] = []
const seen = new Set<number>()

for (const line of stdout.trim().split("\n")) {
if (!line) continue
const [pid, address] = line.split(" ")
const portMatch = address?.match(/:(\d+)$/)
if (!portMatch) continue

const port = parseInt(portMatch[1], 10)
if (seen.has(port)) continue
seen.add(port)

candidates.push({ port, pid: parseInt(pid, 10) })
}
const [localServers, manualServers] = await Promise.all([
discoverLocalServers(),
discoverManualServers(),
])

// Verify candidates with limited concurrency (max 5 at a time)
const tasks = candidates.map((c) => () => verifyOpencodeServer(c))
const results = await promiseAllSettledLimit(tasks, 5)
const servers = results.filter((s): s is DiscoveredServer => s !== null)
const servers = [...localServers, ...manualServers]

const duration = Date.now() - startTime
if (duration > 500) {
console.warn(
`[opencode/servers] Slow discovery: ${duration}ms for ${candidates.length} candidates`,
)
console.warn(`[opencode/servers] Slow discovery: ${duration}ms`)
}

return NextResponse.json(servers, {
Expand All @@ -174,7 +181,6 @@ export async function GET() {
duration: `${duration}ms`,
})

// Return 500 with error details in dev
return NextResponse.json(
{
error: "Server discovery failed",
Expand All @@ -185,3 +191,98 @@ export async function GET() {
)
}
}

async function discoverLocalServers(): Promise<DiscoveredServer[]> {
const { stdout } = await execAsync(
`lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E 'bun|opencode' | awk '{print $2, $9}'`,
{ timeout: 2000 },
).catch((error) => {
if (error.stdout !== undefined) {
return { stdout: error.stdout || "" }
}
console.error("[opencode/servers] lsof failed:", error.message)
throw error
})

const candidates: CandidatePort[] = []
const seen = new Set<number>()

for (const line of stdout.trim().split("\n")) {
if (!line) continue
const [pid, address] = line.split(" ")
const portMatch = address?.match(/:(\d+)$/)
if (!portMatch) continue

const port = parseInt(portMatch[1], 10)
if (seen.has(port)) continue
seen.add(port)

candidates.push({ port, pid: parseInt(pid, 10) })
}

const tasks = candidates.map((c) => () => verifyOpencodeServer(c))
const results = await promiseAllSettledLimit(tasks, 5)
return results.filter((s): s is DiscoveredServer => s !== null)
}

async function discoverManualServers(): Promise<DiscoveredServer[]> {
const manualServers = await readRegistry()
return verifyAndTransformManualServers(manualServers)
}

export async function POST(request: Request) {
try {
const body = await request.json()
const { url, name } = body as { url?: string; name?: string }

if (!url) {
return NextResponse.json({ error: "url is required" }, { status: 400 })
}

const server = await addServer(url, name)

return NextResponse.json(
{
url: server.url,
name: server.name,
proxyPort: server.proxyPort,
addedAt: server.addedAt,
},
{ status: 201 },
)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"

if (message.includes("already registered") || message.includes("Invalid URL")) {
return NextResponse.json({ error: message }, { status: 400 })
}

console.error("[opencode/servers] POST failed:", error)
return NextResponse.json({ error: message }, { status: 500 })
}
}

export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url)
const url = searchParams.get("url")

if (!url) {
return NextResponse.json({ error: "url query param is required" }, { status: 400 })
}

const removed = await removeServer(url)

if (!removed) {
return NextResponse.json({ error: "Server not found" }, { status: 404 })
}

return new NextResponse(null, { status: 204 })
} catch (error) {
console.error("[opencode/servers] DELETE failed:", error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 },
)
}
}
20 changes: 12 additions & 8 deletions apps/web/src/app/api/sse/[port]/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { GET } from "./route"
import { NextRequest } from "next/server"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { GET } from "./route"

describe("SSE Proxy Route /api/sse/[port]", () => {
let originalFetch: typeof global.fetch
Expand Down Expand Up @@ -142,12 +142,16 @@ describe("SSE Proxy Route /api/sse/[port]", () => {
expect(response.body).toBe(mockStream)

// Verify fetch was called with correct URL and headers
expect(global.fetch).toHaveBeenCalledWith("http://127.0.0.1:3000/global/event", {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
expect(global.fetch).toHaveBeenCalledWith(
"http://127.0.0.1:3000/global/event",
expect.objectContaining({ headers: expect.any(Headers) }),
)
const callArgs = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [
string,
{ headers: Headers },
]
expect(callArgs[1].headers.get("Accept")).toBe("text/event-stream")
expect(callArgs[1].headers.get("Cache-Control")).toBe("no-cache")
})

it("accepts valid ports within range", async () => {
Expand Down
27 changes: 20 additions & 7 deletions apps/web/src/app/api/sse/[port]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
import { type NextRequest, NextResponse } from "next/server"
import { createAuthorizationHeader, getManualServerByProxyPort } from "@/lib/manual-server-registry"

export async function GET(request: NextRequest, { params }: { params: Promise<{ port: string }> }) {
const { port } = await params // Next.js 16 requires await
Expand All @@ -15,13 +16,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: "Port out of valid range" }, { status: 400 })
}

// Check if this is a manual (remote) server proxy port
const manualServer = await getManualServerByProxyPort(portNum)
const targetUrl = manualServer
? `${manualServer.url}/global/event`
: `http://127.0.0.1:${portNum}/global/event`

const headers = new Headers({
Accept: "text/event-stream",
"Cache-Control": "no-cache",
})
if (manualServer) {
const authorization = createAuthorizationHeader(manualServer)
if (authorization) {
headers.set("authorization", authorization)
}
}

try {
const response = await fetch(`http://127.0.0.1:${portNum}/global/event`, {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
const response = await fetch(targetUrl, { headers })

if (!response.ok) {
return NextResponse.json(
Expand Down
Loading