Skip to content

Commit b16e2c4

Browse files
committed
refactor(core): split out instance and route through workspaces
1 parent 3845044 commit b16e2c4

7 files changed

Lines changed: 587 additions & 574 deletions

File tree

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)
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)
37+
}
3238

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

packages/opencode/src/server/routes/event.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import { streamSSE } from "hono/streaming"
44
import { Log } from "@/util/log"
55
import { BusEvent } from "@/bus/bus-event"
66
import { Bus } from "@/bus"
7-
import { lazy } from "../../util/lazy"
87
import { AsyncQueue } from "../../util/queue"
98

109
const log = Log.create({ service: "server" })
1110

12-
export const EventRoutes = lazy(() =>
11+
export const EventRoutes = () =>
1312
new Hono().get(
1413
"/event",
1514
describeRoute({
@@ -80,5 +79,4 @@ export const EventRoutes = lazy(() =>
8079
}
8180
})
8281
},
83-
),
84-
)
82+
)

0 commit comments

Comments
 (0)