Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ export type ProjectPortForwardRequest = {
readonly hostPort?: number | undefined
}

export type ProjectBrowserStatus = "running" | "stopped" | "missing" | "unknown"

export type ProjectBrowserSession = {
readonly projectId: string
readonly projectKey: string
readonly containerName: string
readonly status: ProjectBrowserStatus
readonly noVncPath: string
readonly noVncUrl: string
readonly cdpPath: string
readonly cdpUrl: string
}

export type GithubAuthTokenStatus = {
readonly key: string
readonly label: string
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ export const ProjectPortForwardRequestSchema = Schema.Struct({
targetPort: Schema.Number
})

export const ProjectBrowserStatusSchema = Schema.Literal("running", "stopped", "missing", "unknown")

export const ProjectBrowserSessionSchema = Schema.Struct({
cdpPath: Schema.String,
cdpUrl: Schema.String,
containerName: Schema.String,
noVncPath: Schema.String,
noVncUrl: Schema.String,
projectId: Schema.String,
projectKey: Schema.String,
status: ProjectBrowserStatusSchema
})

export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom")

export const AgentEnvVarSchema = Schema.Struct({
Expand Down
19 changes: 17 additions & 2 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import {
upProject
} from "./services/projects.js"
import { readProjectAuthSnapshot, runProjectAuthFlow } from "./services/project-auth.js"
import { readProjectBrowserSession, proxyProjectBrowser } from "./services/project-browser.js"
import { parseProjectBrowserProxyPath } from "./services/project-browser-core.js"
import {
createProjectPortForward,
deleteProjectPortForward,
Expand Down Expand Up @@ -358,9 +360,13 @@ const terminalWebSocketUpgradeResponse = Effect.gen(function*(_) {
)
})

const projectPortProxyResponse = Effect.gen(function*(_) {
const projectProxyResponse = Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const pathname = new URL(request.url, "http://localhost").pathname
const browserTarget = parseProjectBrowserProxyPath(pathname)
if (browserTarget !== null) {
return yield* _(proxyProjectBrowser(request, browserTarget, resolveRequestOrigin(request)))
}
const target = parseProjectPortProxyPath(pathname)
if (target === null) {
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Route not found: ${pathname}` })))
Expand Down Expand Up @@ -726,6 +732,15 @@ export const makeRouter = () => {
Effect.catchAll(errorResponse)
)
),
HttpRouter.get(
"/projects/:projectId/browser",
Effect.gen(function*(_) {
const { projectId } = yield* _(projectParams)
const request = yield* _(HttpServerRequest.HttpServerRequest)
const browser = yield* _(readProjectBrowserSession(projectId, resolveRequestOrigin(request)))
return yield* _(jsonResponse({ browser }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.del(
"/projects/:projectId",
projectParams.pipe(
Expand Down Expand Up @@ -955,7 +970,7 @@ export const makeRouter = () => {
),
HttpRouter.all(
"*",
projectPortProxyResponse.pipe(Effect.catchAll(errorResponse))
projectProxyResponse.pipe(Effect.catchAll(errorResponse))
)
)
}
2 changes: 2 additions & 0 deletions packages/api/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { makeRouter } from "./http.js"
import { initializeAgentState } from "./services/agents.js"
import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js"
import { startOutboxPolling } from "./services/federation.js"
import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js"
import { attachTerminalWebSocketServer } from "./services/terminal-sessions.js"

const resolvePort = (env: Record<string, string | undefined>): number => {
Expand Down Expand Up @@ -46,6 +47,7 @@ export const program = (() => {
const server = createServer()
attachAuthTerminalWebSocketServer(server)
attachTerminalWebSocketServer(server)
attachProjectBrowserWebSocketServer(server)
const serverLayer = NodeHttpServer.layer(() => server, { port })

const pollingInterval = parseInt(process.env["DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS"] ?? "5000", 10)
Expand Down
106 changes: 106 additions & 0 deletions packages/api/src/services/project-browser-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { projectShortKey } from "./project-port-proxy-core.js"

export const browserNoVncPort = 6080
export const browserCdpPort = 9223
export const browserVncPort = 5900

export type ProjectBrowserProxyPath =
| {
readonly _tag: "NoVnc"
readonly projectKey: string
readonly upstreamPath: string
}
| {
readonly _tag: "Cdp"
readonly projectKey: string
readonly upstreamPath: string
}

const browserPathPattern = /^\/(?:api\/)?b\/([a-f0-9]{12})(?:\/(.*))?$/u

export const renderProjectBrowserProxyPath = (projectId: string): string =>
`/b/${projectShortKey(projectId)}/`

export const renderProjectBrowserNoVncPath = (projectId: string): string => {
const projectKey = projectShortKey(projectId)
const params = new URLSearchParams({
autoconnect: "true",
resize: "remote",
path: `b/${projectKey}/websockify`
})
return `/b/${projectKey}/vnc.html?${params.toString()}`
}

export const renderProjectBrowserCdpPath = (projectId: string): string =>
`/b/${projectShortKey(projectId)}/cdp/json/version`

export const parseProjectBrowserProxyPath = (pathname: string): ProjectBrowserProxyPath | null => {
const match = browserPathPattern.exec(pathname)
if (match === null) {
return null
}
const projectKey = match[1]
const rawPath = match[2] ?? ""
if (projectKey === undefined) {
return null
}
if (rawPath === "cdp" || rawPath.startsWith("cdp/")) {
return {
_tag: "Cdp",
projectKey,
upstreamPath: `/${rawPath.slice("cdp".length).replace(/^\/?/u, "")}`
}
}
return {
_tag: "NoVnc",
projectKey,
upstreamPath: `/${rawPath}`
}
}

export const renderExternalUrl = (origin: string, path: string): string => {
const trimmed = origin.endsWith("/") ? origin.slice(0, -1) : origin
return `${trimmed}${path}`
}

export const rewriteCdpWebSocketUrl = (
value: string,
externalOrigin: string,
projectId: string
): string => {
const match = /^wss?:\/\/[^/]+\/(.+)$/u.exec(value)
const upstreamPath = match?.[1]
if (upstreamPath === undefined || upstreamPath.length === 0) {
return value
}
const external = new URL(externalOrigin)
external.protocol = external.protocol === "https:" ? "wss:" : "ws:"
external.pathname = `/b/${projectShortKey(projectId)}/cdp/${upstreamPath}`
external.search = ""
external.hash = ""
return external.toString()
}

export const rewriteCdpVersionPayload = (
payload: string,
externalOrigin: string,
projectId: string
): string => {
let decoded: unknown
try {
decoded = JSON.parse(payload)
} catch {
return payload
}
if (typeof decoded !== "object" || decoded === null || !("webSocketDebuggerUrl" in decoded)) {
return payload
}
const current = decoded.webSocketDebuggerUrl
if (typeof current !== "string") {
return payload
}
return JSON.stringify({
...decoded,
webSocketDebuggerUrl: rewriteCdpWebSocketUrl(current, externalOrigin, projectId)
})
}
Loading