Add CLI instance proxy and route UI traffic through it
This commit is contained in:
5
packages/cli/.npmignore
Normal file
5
packages/cli/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
scripts/
|
||||
src/
|
||||
tsconfig.json
|
||||
*.tsbuildinfo
|
||||
@@ -23,6 +23,8 @@ export interface WorkspaceDescriptor {
|
||||
/** PID/port are populated when the workspace is running. */
|
||||
pid?: number
|
||||
port?: number
|
||||
/** Canonical proxy path the CLI exposes for this instance. */
|
||||
proxyPath: string
|
||||
/** Identifier of the binary resolved from config. */
|
||||
binaryId: string
|
||||
binaryLabel: string
|
||||
|
||||
@@ -136,6 +136,7 @@ async function main() {
|
||||
instanceStore,
|
||||
uiStaticDir: options.uiStaticDir,
|
||||
uiDevServerUrl: options.uiDevServer,
|
||||
logger,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { Readable } from "node:stream"
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
@@ -30,11 +34,13 @@ interface HttpServerDeps {
|
||||
instanceStore: InstanceStore
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
|
||||
const sseClients = new Set<() => void>()
|
||||
const registerSseClient = (cleanup: () => void) => {
|
||||
@@ -59,6 +65,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
@@ -76,6 +83,152 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceProxyDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", { parseAs: "buffer" }, (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
const METHODS_WITHOUT_BODY = new Set(["GET", "HEAD", "OPTIONS"])
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const port = workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
reply.code(502).send({ error: "Workspace instance is not ready" })
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
const bodyPayload = METHODS_WITHOUT_BODY.has(request.method.toUpperCase())
|
||||
? undefined
|
||||
: (request.body as Buffer | undefined)
|
||||
const headers = buildProxyHeaders(request.headers)
|
||||
if (bodyPayload && bodyPayload.byteLength > 0) {
|
||||
headers["content-length"] = String(bodyPayload.byteLength)
|
||||
} else {
|
||||
delete headers["content-length"]
|
||||
}
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: bodyPayload,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
const headersToForward: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === "content-length") {
|
||||
return
|
||||
}
|
||||
headersToForward[key] = value
|
||||
})
|
||||
|
||||
const contentType = (response.headers.get("content-type") ?? "").toLowerCase()
|
||||
const isEventStream = contentType.includes("text/event-stream")
|
||||
|
||||
if (isEventStream && response.body) {
|
||||
reply.hijack()
|
||||
Object.entries(headersToForward).forEach(([key, value]) => reply.raw.setHeader(key, value))
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.writeHead(response.status)
|
||||
const stream = Readable.fromWeb(response.body as NodeReadableStream)
|
||||
const cleanup = () => {
|
||||
stream.destroy()
|
||||
abortController.abort()
|
||||
}
|
||||
request.raw.on("close", cleanup)
|
||||
request.raw.on("error", cleanup)
|
||||
stream.on("error", cleanup)
|
||||
stream.pipe(reply.raw)
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(headersToForward).forEach(([key, value]) => reply.header(key, value))
|
||||
|
||||
reply.code(response.status)
|
||||
|
||||
if (request.method === "HEAD") {
|
||||
reply.send()
|
||||
abortController.abort()
|
||||
return
|
||||
}
|
||||
|
||||
const bodyBuffer = Buffer.from(await response.arrayBuffer())
|
||||
reply.header("content-length", String(bodyBuffer.byteLength))
|
||||
reply.send(bodyBuffer)
|
||||
abortController.abort()
|
||||
} catch (error) {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
}
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
|
||||
@@ -33,6 +33,10 @@ export class WorkspaceManager {
|
||||
return this.workspaces.get(id)
|
||||
}
|
||||
|
||||
getInstancePort(id: string): number | undefined {
|
||||
return this.workspaces.get(id)?.port
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
@@ -57,11 +61,14 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
name,
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: binary.id,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
|
||||
@@ -25,6 +25,8 @@ const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_E
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
|
||||
export const CODENOMAD_API_BASE = API_BASE
|
||||
|
||||
function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { CODENOMAD_API_BASE } from "./api-client"
|
||||
|
||||
class SDKManager {
|
||||
private clients = new Map<number, OpencodeClient>()
|
||||
private clients = new Map<string, OpencodeClient>()
|
||||
|
||||
createClient(port: number): OpencodeClient {
|
||||
if (this.clients.has(port)) {
|
||||
return this.clients.get(port)!
|
||||
createClient(instanceId: string, proxyPath: string): OpencodeClient {
|
||||
if (this.clients.has(instanceId)) {
|
||||
return this.clients.get(instanceId)!
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: `http://localhost:${port}`,
|
||||
})
|
||||
const baseUrl = buildInstanceBaseUrl(proxyPath)
|
||||
const client = createOpencodeClient({ baseUrl })
|
||||
|
||||
this.clients.set(port, client)
|
||||
this.clients.set(instanceId, client)
|
||||
return client
|
||||
}
|
||||
|
||||
getClient(port: number): OpencodeClient | null {
|
||||
return this.clients.get(port) || null
|
||||
getClient(instanceId: string): OpencodeClient | null {
|
||||
return this.clients.get(instanceId) ?? null
|
||||
}
|
||||
|
||||
destroyClient(port: number): void {
|
||||
this.clients.delete(port)
|
||||
destroyClient(instanceId: string): void {
|
||||
this.clients.delete(instanceId)
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
@@ -29,4 +29,19 @@ class SDKManager {
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
const normalized = normalizeProxyPath(proxyPath)
|
||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
||||
return `${base}${normalized}/`
|
||||
}
|
||||
|
||||
function normalizeProxyPath(proxyPath: string): string {
|
||||
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
|
||||
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(input: string): string {
|
||||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export const sdkManager = new SDKManager()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
MessageUpdateEvent,
|
||||
MessageRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessagePartRemovedEvent
|
||||
import {
|
||||
MessageUpdateEvent,
|
||||
MessageRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessagePartRemovedEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventLspUpdated,
|
||||
@@ -14,10 +14,11 @@ import type {
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { CODENOMAD_API_BASE } from "./api-client"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
port: number
|
||||
proxyPath: string
|
||||
eventSource: EventSource
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
reconnectAttempts: number
|
||||
@@ -57,19 +58,19 @@ class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private static readonly MAX_RECONNECT_ATTEMPTS = 3
|
||||
|
||||
connect(instanceId: string, port: number, reconnectAttempts = 0): void {
|
||||
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
|
||||
const existing = this.connections.get(instanceId)
|
||||
if (existing) {
|
||||
this.clearReconnectTimer(existing)
|
||||
existing.eventSource.close()
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/event`
|
||||
const url = buildInstanceEventsUrl(proxyPath)
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
port,
|
||||
proxyPath,
|
||||
eventSource,
|
||||
status: "connecting",
|
||||
reconnectAttempts,
|
||||
@@ -180,7 +181,7 @@ class SSEManager {
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
connection.reconnectTimer = undefined
|
||||
this.connect(instanceId, connection.port, nextAttempt)
|
||||
this.connect(instanceId, connection.proxyPath, nextAttempt)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
@@ -234,4 +235,19 @@ class SSEManager {
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstanceEventsUrl(proxyPath: string): string {
|
||||
const normalized = normalizeProxyPath(proxyPath)
|
||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
||||
return `${base}${normalized}/event`
|
||||
}
|
||||
|
||||
function normalizeProxyPath(proxyPath: string): string {
|
||||
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
|
||||
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(input: string): string {
|
||||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export const sseManager = new SSEManager()
|
||||
|
||||
@@ -45,6 +45,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
folder: descriptor.path,
|
||||
port: descriptor.port ?? existing?.port ?? 0,
|
||||
pid: descriptor.pid ?? existing?.pid ?? 0,
|
||||
proxyPath: descriptor.proxyPath,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
client: existing?.client ?? null,
|
||||
@@ -63,32 +64,39 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready" && descriptor.port) {
|
||||
attachClient(descriptor.id, descriptor.port)
|
||||
if (descriptor.status === "ready") {
|
||||
attachClient(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function attachClient(instanceId: string, port: number) {
|
||||
const instance = instances().get(instanceId)
|
||||
function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
const instance = instances().get(descriptor.id)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port === port && instance.client) {
|
||||
const nextPort = descriptor.port ?? instance.port
|
||||
const nextProxyPath = descriptor.proxyPath
|
||||
|
||||
if (instance.client && instance.proxyPath === nextProxyPath) {
|
||||
if (nextPort && instance.port !== nextPort) {
|
||||
updateInstance(descriptor.id, { port: nextPort })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (instance.port && instance.client) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
sseManager.disconnect(instanceId)
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(descriptor.id)
|
||||
sseManager.disconnect(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
updateInstance(instanceId, {
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
updateInstance(descriptor.id, {
|
||||
client,
|
||||
port,
|
||||
port: nextPort ?? 0,
|
||||
proxyPath: nextProxyPath,
|
||||
status: "ready",
|
||||
})
|
||||
sseManager.connect(instanceId, port)
|
||||
void hydrateInstanceData(instanceId).catch((error) => {
|
||||
sseManager.connect(descriptor.id, nextProxyPath)
|
||||
void hydrateInstanceData(descriptor.id).catch((error) => {
|
||||
console.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
}
|
||||
@@ -97,8 +105,8 @@ function releaseInstanceResources(instanceId: string) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
}
|
||||
sseManager.disconnect(instanceId)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Instance {
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
proxyPath: string
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
client: OpencodeClient | null
|
||||
|
||||
Reference in New Issue
Block a user