mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ea7e75e0b | |||
| 2724335b28 |
@@ -81,6 +81,8 @@
|
|||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
|
"@hono/node-server": "1.19.11",
|
||||||
|
"@hono/node-ws": "1.3.0",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.25.2",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
|||||||
process.env.OPENCODE_CLIENT = "acp"
|
process.env.OPENCODE_CLIENT = "acp"
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
|
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
|||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||||
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { BunWebSocketData } from "hono/bun"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
|
|||||||
Rpc.emit("global.event", event)
|
Rpc.emit("global.event", event)
|
||||||
})
|
})
|
||||||
|
|
||||||
let server: Bun.Server<BunWebSocketData> | undefined
|
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||||
|
|
||||||
const eventStream = {
|
const eventStream = {
|
||||||
abort: undefined as AbortController | undefined,
|
abort: undefined as AbortController | undefined,
|
||||||
@@ -120,7 +119,7 @@ export const rpc = {
|
|||||||
},
|
},
|
||||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||||
if (server) await server.stop(true)
|
if (server) await server.stop(true)
|
||||||
server = Server.listen(input)
|
server = await Server.listen(input)
|
||||||
return { url: server.url.toString() }
|
return { url: server.url.toString() }
|
||||||
},
|
},
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
@@ -143,7 +142,7 @@ export const rpc = {
|
|||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
if (server) server.stop(true)
|
if (server) await server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
|||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
}
|
}
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
const server = await Server.listen(opts)
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(UI.logo(" "))
|
UI.println(UI.logo(" "))
|
||||||
UI.empty()
|
UI.empty()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
|||||||
close: (code?: number, reason?: string) => void
|
close: (code?: number, reason?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||||
|
|
||||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||||
const meta = (cursor: number) => {
|
const meta = (cursor: number) => {
|
||||||
const json = JSON.stringify({ cursor })
|
const json = JSON.stringify({ cursor })
|
||||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -170,21 +172,21 @@ export namespace Pty {
|
|||||||
ptyProcess.onData((chunk) => {
|
ptyProcess.onData((chunk) => {
|
||||||
session.cursor += chunk.length
|
session.cursor += chunk.length
|
||||||
|
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
if (ws.readyState !== 1) {
|
if (ws.readyState !== 1) {
|
||||||
session.subscribers.delete(key)
|
session.subscribers.delete(id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ws.data !== key) {
|
if (key(ws) !== id) {
|
||||||
session.subscribers.delete(key)
|
session.subscribers.delete(id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws.send(chunk)
|
ws.send(chunk)
|
||||||
} catch {
|
} catch {
|
||||||
session.subscribers.delete(key)
|
session.subscribers.delete(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +228,9 @@ export namespace Pty {
|
|||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [id, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (key(ws) === id) ws.close()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -259,16 +261,13 @@ export namespace Pty {
|
|||||||
}
|
}
|
||||||
log.info("client connected to session", { id })
|
log.info("client connected to session", { id })
|
||||||
|
|
||||||
// Use ws.data as the unique key for this connection lifecycle.
|
const sub = key(ws)
|
||||||
// If ws.data is undefined, fallback to ws object.
|
|
||||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
|
||||||
|
|
||||||
// Optionally cleanup if the key somehow exists
|
session.subscribers.delete(sub)
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.set(sub, ws)
|
||||||
session.subscribers.set(connectionKey, ws)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
session.subscribers.delete(connectionKey)
|
session.subscribers.delete(sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = session.bufferCursor
|
const start = session.bufferCursor
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import { upgradeWebSocket } from "hono/bun"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { NotFoundError } from "../../storage/db"
|
import { NotFoundError } from "../../storage/db"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
|
||||||
|
|
||||||
export const PtyRoutes = lazy(() =>
|
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||||
new Hono()
|
return new Hono()
|
||||||
.get(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
)
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider"
|
|||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import { websocket } from "hono/bun"
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
@@ -48,13 +49,20 @@ import { lazy } from "@/util/lazy"
|
|||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
const log = Log.create({ service: "server" })
|
export type Listener = {
|
||||||
|
hostname: string
|
||||||
|
port: number
|
||||||
|
url: URL
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export const Default = lazy(() => createApp({}))
|
export const Default = lazy(() => create({}).app)
|
||||||
|
|
||||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
function create(opts: { cors?: string[] }) {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
return app
|
const ws = createNodeWebSocket({ app })
|
||||||
|
const route = app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
error: err,
|
error: err,
|
||||||
@@ -239,7 +247,6 @@ export namespace Server {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes())
|
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
.route("/experimental", ExperimentalRoutes())
|
.route("/experimental", ExperimentalRoutes())
|
||||||
.route("/session", SessionRoutes())
|
.route("/session", SessionRoutes())
|
||||||
@@ -552,6 +559,7 @@ export namespace Server {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||||
.all("/*", async (c) => {
|
.all("/*", async (c) => {
|
||||||
const path = c.req.path
|
const path = c.req.path
|
||||||
|
|
||||||
@@ -568,6 +576,11 @@ export namespace Server {
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: route as Hono,
|
||||||
|
ws,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
@@ -585,48 +598,86 @@ export namespace Server {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listen(opts: {
|
export async function listen(opts: {
|
||||||
port: number
|
port: number
|
||||||
hostname: string
|
hostname: string
|
||||||
mdns?: boolean
|
mdns?: boolean
|
||||||
mdnsDomain?: string
|
mdnsDomain?: string
|
||||||
cors?: string[]
|
cors?: string[]
|
||||||
}) {
|
}): Promise<Listener> {
|
||||||
const app = createApp(opts)
|
const log = Log.create({ service: "server" })
|
||||||
const args = {
|
const built = create({
|
||||||
hostname: opts.hostname,
|
...opts,
|
||||||
idleTimeout: 0,
|
})
|
||||||
fetch: app.fetch,
|
const start = (port: number) =>
|
||||||
websocket: websocket,
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
} as const
|
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||||
const tryServe = (port: number) => {
|
built.ws.injectWebSocket(server)
|
||||||
try {
|
const fail = (err: Error) => {
|
||||||
return Bun.serve({ ...args, port })
|
cleanup()
|
||||||
} catch {
|
reject(err)
|
||||||
return undefined
|
}
|
||||||
}
|
const ready = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("error", fail)
|
||||||
|
server.off("listening", ready)
|
||||||
|
}
|
||||||
|
server.once("error", fail)
|
||||||
|
server.once("listening", ready)
|
||||||
|
server.listen(port, opts.hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
}
|
}
|
||||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
|
||||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
const url = new URL("http://localhost")
|
||||||
|
url.hostname = opts.hostname
|
||||||
|
url.port = String(addr.port)
|
||||||
|
|
||||||
const shouldPublishMDNS =
|
const shouldPublishMDNS =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
server.port &&
|
addr.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (shouldPublishMDNS) {
|
if (shouldPublishMDNS) {
|
||||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalStop = server.stop.bind(server)
|
let closing: Promise<void> | undefined
|
||||||
server.stop = async (closeActiveConnections?: boolean) => {
|
return {
|
||||||
if (shouldPublishMDNS) MDNS.unpublish()
|
hostname: opts.hostname,
|
||||||
return originalStop(closeActiveConnections)
|
port: addr.port,
|
||||||
|
url,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
if (shouldPublishMDNS) MDNS.unpublish()
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (close) {
|
||||||
|
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||||
|
server.closeAllConnections()
|
||||||
|
}
|
||||||
|
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||||
|
server.closeIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user