refactor(server): simplify router middleware with next() (#21720)
This commit is contained in:
parent
57c40eb7c2
commit
ca5f086759
32 changed files with 767 additions and 467 deletions
|
|
@ -39,6 +39,11 @@
|
||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.ts"
|
"default": "./src/pty/pty.bun.ts"
|
||||||
|
},
|
||||||
|
"#hono": {
|
||||||
|
"bun": "./src/server/adapter.bun.ts",
|
||||||
|
"node": "./src/server/adapter.node.ts",
|
||||||
|
"default": "./src/server/adapter.bun.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -398,13 +398,11 @@ export namespace Agent {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
worker.onerror = (e) => {
|
worker.onerror = (e) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("thread error", {
|
||||||
|
message: e.message,
|
||||||
|
filename: e.filename,
|
||||||
|
lineno: e.lineno,
|
||||||
|
colno: e.colno,
|
||||||
|
error: e.error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = Rpc.client<typeof rpc>(worker)
|
const client = Rpc.client<typeof rpc>(worker)
|
||||||
const error = (e: unknown) => {
|
const error = (e: unknown) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("process error", { error: errorMessage(e) })
|
||||||
}
|
}
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
client.call("reload", undefined).catch((err) => {
|
client.call("reload", undefined).catch((err) => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export namespace Plugin {
|
||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||||
})
|
})
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
|
|
@ -210,13 +210,15 @@ export namespace Plugin {
|
||||||
return message
|
return message
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((message) =>
|
Effect.catch(() => {
|
||||||
bus.publish(Session.Event.Error, {
|
// TODO: make proper events for this
|
||||||
error: new NamedError.Unknown({
|
// bus.publish(Session.Event.Error, {
|
||||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
// error: new NamedError.Unknown({
|
||||||
}).toObject(),
|
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
}),
|
// }).toObject(),
|
||||||
),
|
// })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
packages/opencode/src/server/adapter.bun.ts
Normal file
40
packages/opencode/src/server/adapter.bun.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import { createBunWebSocket } from "hono/bun"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createBunWebSocket()
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const args = {
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: opts.hostname,
|
||||||
|
idleTimeout: 0,
|
||||||
|
websocket: ws.websocket,
|
||||||
|
} as const
|
||||||
|
const start = (port: number) => {
|
||||||
|
try {
|
||||||
|
return Bun.serve({ ...args, port })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||||
|
}
|
||||||
|
if (!server.port) {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
port: server.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
return Promise.resolve(server.stop(close))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
66
packages/opencode/src/server/adapter.node.ts
Normal file
66
packages/opencode/src/server/adapter.node.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createNodeWebSocket({ app })
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const start = (port: number) =>
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
const server = createAdaptorServer({ fetch: app.fetch })
|
||||||
|
ws.injectWebSocket(server)
|
||||||
|
const fail = (err: Error) => {
|
||||||
|
cleanup()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
port: addr.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
21
packages/opencode/src/server/adapter.ts
Normal file
21
packages/opencode/src/server/adapter.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
|
||||||
|
export type Opts = {
|
||||||
|
port: number
|
||||||
|
hostname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Listener = {
|
||||||
|
port: number
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Runtime {
|
||||||
|
upgradeWebSocket: UpgradeWebSocket
|
||||||
|
listen(opts: Opts): Promise<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
create(app: Hono): Runtime
|
||||||
|
}
|
||||||
150
packages/opencode/src/server/control/index.ts
Normal file
150
packages/opencode/src/server/control/index.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { ProviderID } from "@/provider/schema"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||||
|
import z from "zod"
|
||||||
|
import { errors } from "../error"
|
||||||
|
import { GlobalRoutes } from "../instance/global"
|
||||||
|
|
||||||
|
export function ControlPlaneRoutes(): Hono {
|
||||||
|
const app = new Hono()
|
||||||
|
return app
|
||||||
|
.route("/global", GlobalRoutes())
|
||||||
|
.put(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Set auth credentials",
|
||||||
|
description: "Set authentication credentials",
|
||||||
|
operationId: "auth.set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully set authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validator("json", Auth.Info.zod),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
const info = c.req.valid("json")
|
||||||
|
await Auth.set(providerID, info)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Remove auth credentials",
|
||||||
|
description: "Remove authentication credentials",
|
||||||
|
operationId: "auth.remove",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully removed authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
await Auth.remove(providerID)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/doc",
|
||||||
|
openAPIRouteHandler(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "opencode",
|
||||||
|
version: "0.0.3",
|
||||||
|
description: "opencode api",
|
||||||
|
},
|
||||||
|
openapi: "3.1.1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
directory: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/log",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Write log",
|
||||||
|
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||||
|
operationId: "app.log",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Log entry written successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||||
|
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||||
|
message: z.string().meta({ description: "Log message" }),
|
||||||
|
extra: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Additional metadata for the log entry" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { service, level, message, extra } = c.req.valid("json")
|
||||||
|
const logger = Log.create({ service })
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
logger.debug(message, extra)
|
||||||
|
break
|
||||||
|
case "info":
|
||||||
|
logger.info(message, extra)
|
||||||
|
break
|
||||||
|
case "error":
|
||||||
|
logger.error(message, extra)
|
||||||
|
break
|
||||||
|
case "warn":
|
||||||
|
logger.warn(message, extra)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,53 +1,33 @@
|
||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createHash } from "node:crypto"
|
import { Format } from "../../format"
|
||||||
import * as fs from "node:fs/promises"
|
import { TuiRoutes } from "./tui"
|
||||||
import { Log } from "../util/log"
|
import { Instance } from "../../project/instance"
|
||||||
import { Format } from "../format"
|
import { Vcs } from "../../project/vcs"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Instance } from "../project/instance"
|
import { Skill } from "../../skill"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Global } from "../../global"
|
||||||
import { Agent } from "../agent/agent"
|
import { LSP } from "../../lsp"
|
||||||
import { Skill } from "../skill"
|
import { Command } from "../../command"
|
||||||
import { Global } from "../global"
|
import { QuestionRoutes } from "./question"
|
||||||
import { LSP } from "../lsp"
|
import { PermissionRoutes } from "./permission"
|
||||||
import { Command } from "../command"
|
import { ProjectRoutes } from "./project"
|
||||||
import { Flag } from "../flag/flag"
|
import { SessionRoutes } from "./session"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { PtyRoutes } from "./pty"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { McpRoutes } from "./mcp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { FileRoutes } from "./file"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ConfigRoutes } from "./config"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { ExperimentalRoutes } from "./experimental"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
import { ProviderRoutes } from "./provider"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { EventRoutes } from "./event"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
|
||||||
import { EventRoutes } from "./routes/event"
|
|
||||||
import { errorHandler } from "./middleware"
|
|
||||||
import { getMimeType } from "hono/utils/mime"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||||
|
new Hono()
|
||||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
.use(WorkspaceRouterMiddleware(upgrade))
|
||||||
? Promise.resolve(null)
|
|
||||||
: // @ts-expect-error - generated file at build time
|
|
||||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
|
||||||
|
|
||||||
const DEFAULT_CSP =
|
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
|
||||||
|
|
||||||
const csp = (hash = "") =>
|
|
||||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
|
||||||
|
|
||||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
|
||||||
app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes(upgrade))
|
.route("/pty", PtyRoutes(upgrade))
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
|
|
@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
||||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
|
||||||
const embeddedWebUI = await embeddedUIPromise
|
|
||||||
const path = c.req.path
|
|
||||||
|
|
||||||
if (embeddedWebUI) {
|
|
||||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
|
||||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
|
||||||
|
|
||||||
if (await fs.exists(match)) {
|
|
||||||
const mime = getMimeType(match) ?? "text/plain"
|
|
||||||
c.header("Content-Type", mime)
|
|
||||||
if (mime.startsWith("text/html")) {
|
|
||||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
|
||||||
}
|
|
||||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
|
||||||
} else {
|
|
||||||
return c.json({ error: "Not Found" }, 404)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
|
||||||
...c.req,
|
|
||||||
headers: {
|
|
||||||
...c.req.raw.headers,
|
|
||||||
host: "app.opencode.ai",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const match = response.headers.get("content-type")?.includes("text/html")
|
|
||||||
? (await response.clone().text()).match(
|
|
||||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
|
||||||
response.headers.set("Content-Security-Policy", csp(hash))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import { getAdaptor } from "@/control-plane/adaptors"
|
import { getAdaptor } from "@/control-plane/adaptors"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { ServerProxy } from "./proxy"
|
import { ServerProxy } from "../proxy"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
return async (c, next) => {
|
||||||
|
|
||||||
return async (c) => {
|
|
||||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||||
const directory = Filesystem.resolve(
|
const directory = Filesystem.resolve(
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||||
// made sure that it will run without an instance
|
// made sure that it will run without an instance
|
||||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
|
|
@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
if (local(c.req.method, url.pathname)) {
|
if (local(c.req.method, url.pathname)) {
|
||||||
// No instance provided because we are serving cached data; there
|
// No instance provided because we are serving cached data; there
|
||||||
// is no instance to work with
|
// is no instance to work with
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||||
|
|
@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import type { ErrorHandler } from "hono"
|
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import type { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { basicAuth } from "hono/basic-auth"
|
||||||
|
import { cors } from "hono/cors"
|
||||||
|
import { compress } from "hono/compress"
|
||||||
|
|
||||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
const log = Log.create({ service: "server" })
|
||||||
return (err, c) => {
|
|
||||||
log.error("failed", {
|
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||||
error: err,
|
log.error("failed", {
|
||||||
})
|
error: err,
|
||||||
if (err instanceof NamedError) {
|
})
|
||||||
let status: ContentfulStatusCode
|
if (err instanceof NamedError) {
|
||||||
if (err instanceof NotFoundError) status = 404
|
let status: ContentfulStatusCode
|
||||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
if (err instanceof NotFoundError) status = 404
|
||||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||||
else if (err.name.startsWith("Worktree")) status = 400
|
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||||
else status = 500
|
else if (err.name.startsWith("Worktree")) status = 400
|
||||||
return c.json(err.toObject(), { status })
|
else status = 500
|
||||||
}
|
return c.json(err.toObject(), { status })
|
||||||
if (err instanceof Session.BusyError) {
|
}
|
||||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
if (err instanceof Session.BusyError) {
|
||||||
}
|
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||||
if (err instanceof HTTPException) return err.getResponse()
|
}
|
||||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
if (err instanceof HTTPException) return err.getResponse()
|
||||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||||
status: 500,
|
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
// Allow CORS preflight requests to succeed without auth.
|
||||||
|
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||||
|
if (c.req.method === "OPTIONS") return next()
|
||||||
|
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
if (!password) return next()
|
||||||
|
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||||
|
|
||||||
|
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||||
|
|
||||||
|
return basicAuth({ username, password })(c, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
const skip = c.req.path === "/log"
|
||||||
|
if (!skip) {
|
||||||
|
log.info("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const timer = log.time("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
})
|
||||||
|
await next()
|
||||||
|
if (!skip) timer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||||
|
return cors({
|
||||||
|
maxAge: 86_400,
|
||||||
|
origin(input) {
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
if (input.startsWith("http://localhost:")) return input
|
||||||
|
if (input.startsWith("http://127.0.0.1:")) return input
|
||||||
|
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||||
|
return input
|
||||||
|
|
||||||
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||||
|
if (opts?.cors?.includes(input)) return input
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipped = compress()
|
||||||
|
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
const path = c.req.path
|
||||||
|
const method = c.req.method
|
||||||
|
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||||
|
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||||
|
return zipped(c, next)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
import { Log } from "../util/log"
|
import { generateSpecs } from "hono-openapi"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { compress } from "hono/compress"
|
import { adapter } from "#hono"
|
||||||
import { createNodeWebSocket } from "@hono/node-ws"
|
|
||||||
import { cors } from "hono/cors"
|
|
||||||
import { basicAuth } from "hono/basic-auth"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
|
||||||
import z from "zod"
|
|
||||||
import { Auth } from "../auth"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { ProviderID } from "../provider/schema"
|
|
||||||
import { WorkspaceRouterMiddleware } from "./router"
|
|
||||||
import { errors } from "./error"
|
|
||||||
import { GlobalRoutes } from "./routes/global"
|
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { errorHandler } from "./middleware"
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||||
import { InstanceRoutes } from "./instance"
|
import { InstanceRoutes } from "./instance"
|
||||||
import { initProjectors } from "./projectors"
|
import { initProjectors } from "./projectors"
|
||||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
import { Log } from "@/util/log"
|
||||||
|
import { ControlPlaneRoutes } from "./control"
|
||||||
|
import { UIRoutes } from "./ui"
|
||||||
|
|
||||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
initProjectors()
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export type Listener = {
|
export type Listener = {
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
|
|
@ -33,231 +25,31 @@ export namespace Server {
|
||||||
stop: (close?: boolean) => Promise<void>
|
stop: (close?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
const zipped = compress()
|
|
||||||
|
|
||||||
const skipCompress = (path: string, method: string) => {
|
|
||||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
|
||||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = lazy(() => create({}))
|
export const Default = lazy(() => create({}))
|
||||||
|
|
||||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
|
||||||
return app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.use((c, next) => {
|
|
||||||
// Allow CORS preflight requests to succeed without auth.
|
|
||||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
|
||||||
if (c.req.method === "OPTIONS") return next()
|
|
||||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
if (!password) return next()
|
|
||||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
||||||
|
|
||||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
|
||||||
|
|
||||||
return basicAuth({ username, password })(c, next)
|
|
||||||
})
|
|
||||||
.use(async (c, next) => {
|
|
||||||
const skip = c.req.path === "/log"
|
|
||||||
if (!skip) {
|
|
||||||
log.info("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const timer = log.time("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
await next()
|
|
||||||
if (!skip) timer.stop()
|
|
||||||
})
|
|
||||||
.use(
|
|
||||||
cors({
|
|
||||||
maxAge: 86_400,
|
|
||||||
origin(input) {
|
|
||||||
if (!input) return
|
|
||||||
|
|
||||||
if (input.startsWith("http://localhost:")) return input
|
|
||||||
if (input.startsWith("http://127.0.0.1:")) return input
|
|
||||||
if (
|
|
||||||
input === "tauri://localhost" ||
|
|
||||||
input === "http://tauri.localhost" ||
|
|
||||||
input === "https://tauri.localhost"
|
|
||||||
)
|
|
||||||
return input
|
|
||||||
|
|
||||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
|
||||||
if (opts?.cors?.includes(input)) return input
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use((c, next) => {
|
|
||||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
|
||||||
return zipped(c, next)
|
|
||||||
})
|
|
||||||
.route("/global", GlobalRoutes())
|
|
||||||
.put(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Set auth credentials",
|
|
||||||
description: "Set authentication credentials",
|
|
||||||
operationId: "auth.set",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully set authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
validator("json", Auth.Info.zod),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
const info = c.req.valid("json")
|
|
||||||
await Auth.set(providerID, info)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.delete(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Remove auth credentials",
|
|
||||||
description: "Remove authentication credentials",
|
|
||||||
operationId: "auth.remove",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully removed authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
await Auth.remove(providerID)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/doc",
|
|
||||||
openAPIRouteHandler(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "opencode",
|
|
||||||
version: "0.0.3",
|
|
||||||
description: "opencode api",
|
|
||||||
},
|
|
||||||
openapi: "3.1.1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use(
|
|
||||||
validator(
|
|
||||||
"query",
|
|
||||||
z.object({
|
|
||||||
directory: z.string().optional(),
|
|
||||||
workspace: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/log",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Write log",
|
|
||||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
|
||||||
operationId: "app.log",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Log entry written successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
|
||||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
|
||||||
message: z.string().meta({ description: "Log message" }),
|
|
||||||
extra: z
|
|
||||||
.record(z.string(), z.any())
|
|
||||||
.optional()
|
|
||||||
.meta({ description: "Additional metadata for the log entry" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { service, level, message, extra } = c.req.valid("json")
|
|
||||||
const logger = Log.create({ service })
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case "debug":
|
|
||||||
logger.debug(message, extra)
|
|
||||||
break
|
|
||||||
case "info":
|
|
||||||
logger.info(message, extra)
|
|
||||||
break
|
|
||||||
case "error":
|
|
||||||
logger.error(message, extra)
|
|
||||||
break
|
|
||||||
case "warn":
|
|
||||||
logger.warn(message, extra)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.use(WorkspaceRouterMiddleware(upgrade))
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(opts: { cors?: string[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const ws = createNodeWebSocket({ app })
|
const runtime = adapter.create(app)
|
||||||
return {
|
return {
|
||||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
app: app
|
||||||
ws,
|
.onError(ErrorMiddleware)
|
||||||
|
.use(AuthMiddleware)
|
||||||
|
.use(LoggerMiddleware)
|
||||||
|
.use(CompressionMiddleware)
|
||||||
|
.use(CorsMiddleware(opts))
|
||||||
|
.route("/", ControlPlaneRoutes())
|
||||||
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||||
|
.route("/", UIRoutes()),
|
||||||
|
runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(opts: { cors?: string[] }) {
|
|
||||||
return create(opts).app
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
// Build a fresh app with all routes registered directly so
|
// Build a fresh app with all routes registered directly so
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
// strips the metadata symbol).
|
// strips the metadata symbol).
|
||||||
const { app, ws } = create({})
|
const { app } = create({})
|
||||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
|
||||||
const result = await generateSpecs(app, {
|
const result = await generateSpecs(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
|
|
@ -281,46 +73,21 @@ export namespace Server {
|
||||||
cors?: string[]
|
cors?: string[]
|
||||||
}): Promise<Listener> {
|
}): Promise<Listener> {
|
||||||
const built = create(opts)
|
const built = create(opts)
|
||||||
const start = (port: number) =>
|
const server = await built.runtime.listen(opts)
|
||||||
new Promise<ServerType>((resolve, reject) => {
|
|
||||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
|
||||||
built.ws.injectWebSocket(server)
|
|
||||||
const fail = (err: Error) => {
|
|
||||||
cleanup()
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
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 next = new URL("http://localhost")
|
const next = new URL("http://localhost")
|
||||||
next.hostname = opts.hostname
|
next.hostname = opts.hostname
|
||||||
next.port = String(addr.port)
|
next.port = String(server.port)
|
||||||
url = next
|
url = next
|
||||||
|
|
||||||
const mdns =
|
const mdns =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
addr.port &&
|
server.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 (mdns) {
|
if (mdns) {
|
||||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
MDNS.publish(server.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")
|
||||||
}
|
}
|
||||||
|
|
@ -328,27 +95,13 @@ export namespace Server {
|
||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
port: addr.port,
|
port: server.port,
|
||||||
url: next,
|
url: next,
|
||||||
stop(close?: boolean) {
|
stop(close?: boolean) {
|
||||||
closing ??= new Promise((resolve, reject) => {
|
closing ??= (async () => {
|
||||||
if (mdns) MDNS.unpublish()
|
if (mdns) MDNS.unpublish()
|
||||||
server.close((err) => {
|
await server.stop(close)
|
||||||
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 closing
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
packages/opencode/src/server/ui/index.ts
Normal file
55
packages/opencode/src/server/ui/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { proxy } from "hono/proxy"
|
||||||
|
import { getMimeType } from "hono/utils/mime"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
|
||||||
|
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: // @ts-expect-error - generated file at build time
|
||||||
|
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||||
|
|
||||||
|
const DEFAULT_CSP =
|
||||||
|
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||||
|
|
||||||
|
const csp = (hash = "") =>
|
||||||
|
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||||
|
|
||||||
|
export const UIRoutes = (): Hono =>
|
||||||
|
new Hono().all("/*", async (c) => {
|
||||||
|
const embeddedWebUI = await embeddedUIPromise
|
||||||
|
const path = c.req.path
|
||||||
|
|
||||||
|
if (embeddedWebUI) {
|
||||||
|
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||||
|
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||||
|
|
||||||
|
if (await fs.exists(match)) {
|
||||||
|
const mime = getMimeType(match) ?? "text/plain"
|
||||||
|
c.header("Content-Type", mime)
|
||||||
|
if (mime.startsWith("text/html")) {
|
||||||
|
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||||
|
}
|
||||||
|
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Not Found" }, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||||
|
...c.req,
|
||||||
|
headers: {
|
||||||
|
...c.req.raw.headers,
|
||||||
|
host: "app.opencode.ai",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const match = response.headers.get("content-type")?.includes("text/html")
|
||||||
|
? (await response.clone().text()).match(
|
||||||
|
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||||
|
response.headers.set("Content-Security-Policy", csp(hash))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
})
|
||||||
49
packages/opencode/test/memory/abort-leak-webfetch.ts
Normal file
49
packages/opencode/test/memory/abort-leak-webfetch.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { abortAfterAny } from "../../src/util/abort"
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const heap = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
return new Response("hello from local", {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${server.port}`
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
await response.text()
|
||||||
|
} finally {
|
||||||
|
clearTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run()
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = heap()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
await run()
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = heap()
|
||||||
|
process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
127
packages/opencode/test/memory/abort-leak.test.ts
Normal file
127
packages/opencode/test/memory/abort-leak.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
const projectRoot = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const getHeapMB = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("memory: abort controller leak", () => {
|
||||||
|
test("webfetch does not leak memory over many invocations", async () => {
|
||||||
|
// Measure the abort-timed fetch path in a fresh process so shared tool
|
||||||
|
// runtime state does not dominate the heap signal.
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: [process.execPath, worker],
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [code, stdout, stderr] = await Promise.all([
|
||||||
|
proc.exited,
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout.trim()) as {
|
||||||
|
baseline: number
|
||||||
|
after: number
|
||||||
|
growth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
|
||||||
|
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
|
||||||
|
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
|
||||||
|
|
||||||
|
// Memory growth should be minimal - less than 1MB per 10 requests.
|
||||||
|
expect(result.growth).toBeLessThan(ITERATIONS / 10)
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
test("compare closure vs bind pattern directly", async () => {
|
||||||
|
const ITERATIONS = 500
|
||||||
|
|
||||||
|
// Test OLD pattern: arrow function closure
|
||||||
|
// Store closures in a map keyed by content to force retention
|
||||||
|
const closureMap = new Map<string, () => void>()
|
||||||
|
const timers: Timer[] = []
|
||||||
|
const controllers: AbortController[] = []
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = getHeapMB()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
// Simulate large response body like webfetch would have
|
||||||
|
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.push(controller)
|
||||||
|
|
||||||
|
// OLD pattern - closure captures `content`
|
||||||
|
const handler = () => {
|
||||||
|
// Actually use content so it can't be optimized away
|
||||||
|
if (content.length > 1000000000) controller.abort()
|
||||||
|
}
|
||||||
|
closureMap.set(content, handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = getHeapMB()
|
||||||
|
const oldGrowth = after - baseline
|
||||||
|
|
||||||
|
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers.forEach(clearTimeout)
|
||||||
|
controllers.forEach((c) => c.abort())
|
||||||
|
closureMap.clear()
|
||||||
|
|
||||||
|
// Test NEW pattern: bind
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline2 = getHeapMB()
|
||||||
|
const handlers2: (() => void)[] = []
|
||||||
|
const timers2: Timer[] = []
|
||||||
|
const controllers2: AbortController[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers2.push(controller)
|
||||||
|
|
||||||
|
// NEW pattern - bind doesn't capture surrounding scope
|
||||||
|
const handler = controller.abort.bind(controller)
|
||||||
|
handlers2.push(handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers2.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after2 = getHeapMB()
|
||||||
|
const newGrowth = after2 - baseline2
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers2.forEach(clearTimeout)
|
||||||
|
controllers2.forEach((c) => c.abort())
|
||||||
|
handlers2.length = 0
|
||||||
|
|
||||||
|
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
|
||||||
|
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
|
||||||
|
|
||||||
|
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
|
||||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm")
|
const { Npm } = await import("../../src/npm")
|
||||||
const { Bus } = await import("../../src/bus")
|
|
||||||
const { Session } = await import("../../src/session")
|
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
if (disableDefault === undefined) {
|
if (disableDefault === undefined) {
|
||||||
|
|
@ -37,27 +35,6 @@ async function load(dir: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errs(dir: string) {
|
|
||||||
return Instance.provide({
|
|
||||||
directory: dir,
|
|
||||||
fn: async () => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
|
||||||
const error = evt.properties.error
|
|
||||||
if (!error || typeof error !== "object") return
|
|
||||||
if (!("data" in error)) return
|
|
||||||
if (!error.data || typeof error.data !== "object") return
|
|
||||||
if (!("message" in error.data)) return
|
|
||||||
if (typeof error.data.message !== "string") return
|
|
||||||
errors.push(error.data.message)
|
|
||||||
})
|
|
||||||
await Plugin.list()
|
|
||||||
off()
|
|
||||||
return errors
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("plugin.loader.shared", () => {
|
describe("plugin.loader.shared", () => {
|
||||||
test("loads a file:// plugin function export", async () => {
|
test("loads a file:// plugin function export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
|
|
@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects v1 plugin that exports server and tui together", async () => {
|
test("rejects v1 plugin that exports server and tui together", async () => {
|
||||||
|
|
@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves npm plugin specs with explicit and default versions", async () => {
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||||
|
|
@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
|
|
@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
|
|
@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
|
|
@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
|
|
@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when install fails", async () => {
|
test("skips broken plugin when install fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
|
const ok = path.join(dir, "ok.ts")
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
ok,
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
||||||
|
)
|
||||||
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
||||||
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
true,
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin init throws", async () => {
|
test("continues loading plugins when plugin init throws", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "throws.ts"),
|
path.join(dir, "throws.ts"),
|
||||||
[
|
[
|
||||||
|
|
@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin module has invalid export", async () => {
|
test("continues loading plugins when plugin module has invalid export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "invalid.ts"),
|
path.join(dir, "invalid.ts"),
|
||||||
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin import fails", async () => {
|
test("continues loading plugins when plugin import fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
||||||
|
|
||||||
return { missing }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads object plugin via plugin.server", async () => {
|
test("loads object plugin via plugin.server", async () => {
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
|
||||||
|
|
||||||
describe("session.prompt_async error handling", () => {
|
describe("session.prompt_async error handling", () => {
|
||||||
test("prompt_async route has error handler for detached prompt call", async () => {
|
test("prompt_async route has error handler for detached prompt call", async () => {
|
||||||
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
|
const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
|
||||||
const start = src.indexOf('"/:sessionID/prompt_async"')
|
const start = src.indexOf('"/:sessionID/prompt_async"')
|
||||||
const end = src.indexOf('"/:sessionID/command"', start)
|
const end = src.indexOf('"/:sessionID/command"', start)
|
||||||
expect(start).toBeGreaterThan(-1)
|
expect(start).toBeGreaterThan(-1)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue