Skip to content

Commit a76be69

Browse files
authored
refactor(core): split out instance and route through workspaces (anomalyco#19335)
1 parent e528ed5 commit a76be69

File tree

8 files changed

+622
-598
lines changed

8 files changed

+622
-598
lines changed

packages/opencode/src/control-plane/workspace-router-middleware.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Flag } from "../flag/flag"
33
import { getAdaptor } from "./adaptors"
44
import { WorkspaceID } from "./schema"
55
import { Workspace } from "./workspace"
6+
import { InstanceRoutes } from "../server/instance"
7+
import { lazy } from "../util/lazy"
68

79
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
810

@@ -20,16 +22,25 @@ function local(method: string, path: string) {
2022
return false
2123
}
2224

23-
async function routeRequest(req: Request) {
24-
const url = new URL(req.url)
25-
const raw = url.searchParams.get("workspace") || req.headers.get("x-opencode-workspace")
25+
const routes = lazy(() => InstanceRoutes())
2626

27-
if (!raw) return
27+
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
28+
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
29+
return routes().fetch(c.req.raw, c.env)
30+
}
2831

29-
if (local(req.method, url.pathname)) return
32+
const url = new URL(c.req.url)
33+
const raw = url.searchParams.get("workspace")
3034

31-
const workspaceID = WorkspaceID.make(raw)
35+
if (!raw) {
36+
return routes().fetch(c.req.raw, c.env)
37+
}
3238

39+
if (local(c.req.method, url.pathname)) {
40+
return routes().fetch(c.req.raw, c.env)
41+
}
42+
43+
const workspaceID = WorkspaceID.make(raw)
3344
const workspace = await Workspace.get(workspaceID)
3445
if (!workspace) {
3546
return new Response(`Workspace not found: ${workspaceID}`, {
@@ -41,27 +52,13 @@ async function routeRequest(req: Request) {
4152
}
4253

4354
const adaptor = await getAdaptor(workspace.type)
44-
45-
const headers = new Headers(req.headers)
55+
const headers = new Headers(c.req.raw.headers)
4656
headers.delete("x-opencode-workspace")
4757

4858
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
49-
method: req.method,
50-
body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
51-
signal: req.signal,
59+
method: c.req.method,
60+
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
61+
signal: c.req.raw.signal,
5262
headers,
5363
})
5464
}
55-
56-
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
57-
// Only available in development for now
58-
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
59-
return next()
60-
}
61-
62-
const response = await routeRequest(c.req.raw)
63-
if (response) {
64-
return response
65-
}
66-
return next()
67-
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { describeRoute, resolver } from "hono-openapi"
2+
import { Hono } from "hono"
3+
import { proxy } from "hono/proxy"
4+
import z from "zod"
5+
import { createHash } from "node:crypto"
6+
import { Log } from "../util/log"
7+
import { Format } from "../format"
8+
import { TuiRoutes } from "./routes/tui"
9+
import { Instance } from "../project/instance"
10+
import { Vcs } from "../project/vcs"
11+
import { Agent } from "../agent/agent"
12+
import { Skill } from "../skill"
13+
import { Global } from "../global"
14+
import { LSP } from "../lsp"
15+
import { Command } from "../command"
16+
import { Flag } from "../flag/flag"
17+
import { Filesystem } from "@/util/filesystem"
18+
import { QuestionRoutes } from "./routes/question"
19+
import { PermissionRoutes } from "./routes/permission"
20+
import { ProjectRoutes } from "./routes/project"
21+
import { SessionRoutes } from "./routes/session"
22+
import { PtyRoutes } from "./routes/pty"
23+
import { McpRoutes } from "./routes/mcp"
24+
import { FileRoutes } from "./routes/file"
25+
import { ConfigRoutes } from "./routes/config"
26+
import { ExperimentalRoutes } from "./routes/experimental"
27+
import { ProviderRoutes } from "./routes/provider"
28+
import { EventRoutes } from "./routes/event"
29+
import { InstanceBootstrap } from "../project/bootstrap"
30+
import { errorHandler } from "./middleware"
31+
32+
const log = Log.create({ service: "server" })
33+
34+
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
35+
? Promise.resolve(null)
36+
: // @ts-expect-error - generated file at build time
37+
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
38+
39+
const DEFAULT_CSP =
40+
"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:"
41+
42+
const csp = (hash = "") =>
43+
`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:`
44+
45+
export const InstanceRoutes = (app?: Hono) =>
46+
(app ?? new Hono())
47+
.onError(errorHandler(log))
48+
.use(async (c, next) => {
49+
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
50+
const directory = Filesystem.resolve(
51+
(() => {
52+
try {
53+
return decodeURIComponent(raw)
54+
} catch {
55+
return raw
56+
}
57+
})(),
58+
)
59+
60+
return Instance.provide({
61+
directory,
62+
init: InstanceBootstrap,
63+
async fn() {
64+
return next()
65+
},
66+
})
67+
})
68+
.route("/project", ProjectRoutes())
69+
.route("/pty", PtyRoutes())
70+
.route("/config", ConfigRoutes())
71+
.route("/experimental", ExperimentalRoutes())
72+
.route("/session", SessionRoutes())
73+
.route("/permission", PermissionRoutes())
74+
.route("/question", QuestionRoutes())
75+
.route("/provider", ProviderRoutes())
76+
.route("/", FileRoutes())
77+
.route("/", EventRoutes())
78+
.route("/mcp", McpRoutes())
79+
.route("/tui", TuiRoutes())
80+
.post(
81+
"/instance/dispose",
82+
describeRoute({
83+
summary: "Dispose instance",
84+
description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
85+
operationId: "instance.dispose",
86+
responses: {
87+
200: {
88+
description: "Instance disposed",
89+
content: {
90+
"application/json": {
91+
schema: resolver(z.boolean()),
92+
},
93+
},
94+
},
95+
},
96+
}),
97+
async (c) => {
98+
await Instance.dispose()
99+
return c.json(true)
100+
},
101+
)
102+
.get(
103+
"/path",
104+
describeRoute({
105+
summary: "Get paths",
106+
description: "Retrieve the current working directory and related path information for the OpenCode instance.",
107+
operationId: "path.get",
108+
responses: {
109+
200: {
110+
description: "Path",
111+
content: {
112+
"application/json": {
113+
schema: resolver(
114+
z
115+
.object({
116+
home: z.string(),
117+
state: z.string(),
118+
config: z.string(),
119+
worktree: z.string(),
120+
directory: z.string(),
121+
})
122+
.meta({
123+
ref: "Path",
124+
}),
125+
),
126+
},
127+
},
128+
},
129+
},
130+
}),
131+
async (c) => {
132+
return c.json({
133+
home: Global.Path.home,
134+
state: Global.Path.state,
135+
config: Global.Path.config,
136+
worktree: Instance.worktree,
137+
directory: Instance.directory,
138+
})
139+
},
140+
)
141+
.get(
142+
"/vcs",
143+
describeRoute({
144+
summary: "Get VCS info",
145+
description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
146+
operationId: "vcs.get",
147+
responses: {
148+
200: {
149+
description: "VCS info",
150+
content: {
151+
"application/json": {
152+
schema: resolver(Vcs.Info),
153+
},
154+
},
155+
},
156+
},
157+
}),
158+
async (c) => {
159+
const branch = await Vcs.branch()
160+
return c.json({
161+
branch,
162+
})
163+
},
164+
)
165+
.get(
166+
"/command",
167+
describeRoute({
168+
summary: "List commands",
169+
description: "Get a list of all available commands in the OpenCode system.",
170+
operationId: "command.list",
171+
responses: {
172+
200: {
173+
description: "List of commands",
174+
content: {
175+
"application/json": {
176+
schema: resolver(Command.Info.array()),
177+
},
178+
},
179+
},
180+
},
181+
}),
182+
async (c) => {
183+
const commands = await Command.list()
184+
return c.json(commands)
185+
},
186+
)
187+
.get(
188+
"/agent",
189+
describeRoute({
190+
summary: "List agents",
191+
description: "Get a list of all available AI agents in the OpenCode system.",
192+
operationId: "app.agents",
193+
responses: {
194+
200: {
195+
description: "List of agents",
196+
content: {
197+
"application/json": {
198+
schema: resolver(Agent.Info.array()),
199+
},
200+
},
201+
},
202+
},
203+
}),
204+
async (c) => {
205+
const modes = await Agent.list()
206+
return c.json(modes)
207+
},
208+
)
209+
.get(
210+
"/skill",
211+
describeRoute({
212+
summary: "List skills",
213+
description: "Get a list of all available skills in the OpenCode system.",
214+
operationId: "app.skills",
215+
responses: {
216+
200: {
217+
description: "List of skills",
218+
content: {
219+
"application/json": {
220+
schema: resolver(Skill.Info.array()),
221+
},
222+
},
223+
},
224+
},
225+
}),
226+
async (c) => {
227+
const skills = await Skill.all()
228+
return c.json(skills)
229+
},
230+
)
231+
.get(
232+
"/lsp",
233+
describeRoute({
234+
summary: "Get LSP status",
235+
description: "Get LSP server status",
236+
operationId: "lsp.status",
237+
responses: {
238+
200: {
239+
description: "LSP server status",
240+
content: {
241+
"application/json": {
242+
schema: resolver(LSP.Status.array()),
243+
},
244+
},
245+
},
246+
},
247+
}),
248+
async (c) => {
249+
return c.json(await LSP.status())
250+
},
251+
)
252+
.get(
253+
"/formatter",
254+
describeRoute({
255+
summary: "Get formatter status",
256+
description: "Get formatter status",
257+
operationId: "formatter.status",
258+
responses: {
259+
200: {
260+
description: "Formatter status",
261+
content: {
262+
"application/json": {
263+
schema: resolver(Format.Status.array()),
264+
},
265+
},
266+
},
267+
},
268+
}),
269+
async (c) => {
270+
return c.json(await Format.status())
271+
},
272+
)
273+
.all("/*", async (c) => {
274+
const embeddedWebUI = await embeddedUIPromise
275+
const path = c.req.path
276+
277+
if (embeddedWebUI) {
278+
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
279+
if (!match) return c.json({ error: "Not Found" }, 404)
280+
const file = Bun.file(match)
281+
if (await file.exists()) {
282+
c.header("Content-Type", file.type)
283+
if (file.type.startsWith("text/html")) {
284+
c.header("Content-Security-Policy", DEFAULT_CSP)
285+
}
286+
return c.body(await file.arrayBuffer())
287+
} else {
288+
return c.json({ error: "Not Found" }, 404)
289+
}
290+
} else {
291+
const response = await proxy(`https://app.opencode.ai${path}`, {
292+
...c.req,
293+
headers: {
294+
...c.req.raw.headers,
295+
host: "app.opencode.ai",
296+
},
297+
})
298+
const match = response.headers.get("content-type")?.includes("text/html")
299+
? (await response.clone().text()).match(
300+
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
301+
)
302+
: undefined
303+
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
304+
response.headers.set("Content-Security-Policy", csp(hash))
305+
return response
306+
}
307+
})

0 commit comments

Comments
 (0)