diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index f842617a..829d16cb 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -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 diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index f2a2f232..f2a0e963 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -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({ diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index fe9d15c5..5dc480b5 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -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, @@ -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}` }))) @@ -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( @@ -955,7 +970,7 @@ export const makeRouter = () => { ), HttpRouter.all( "*", - projectPortProxyResponse.pipe(Effect.catchAll(errorResponse)) + projectProxyResponse.pipe(Effect.catchAll(errorResponse)) ) ) } diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts index 2b0eb93b..093f43d1 100644 --- a/packages/api/src/program.ts +++ b/packages/api/src/program.ts @@ -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): number => { @@ -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) diff --git a/packages/api/src/services/project-browser-core.ts b/packages/api/src/services/project-browser-core.ts new file mode 100644 index 00000000..aefad337 --- /dev/null +++ b/packages/api/src/services/project-browser-core.ts @@ -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) + }) +} diff --git a/packages/api/src/services/project-browser.ts b/packages/api/src/services/project-browser.ts new file mode 100644 index 00000000..470e0635 --- /dev/null +++ b/packages/api/src/services/project-browser.ts @@ -0,0 +1,693 @@ +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-inspect-parse" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { loadProjectIndex, loadProjectStatus } from "@effect-template/lib/usecases/projects-core" +import type { ListProjectsContext } from "@effect-template/lib/usecases/projects-list" +import { NodeContext } from "@effect/platform-node" +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as HttpServerError from "@effect/platform/HttpServerError" +import * as HttpServerRequest from "@effect/platform/HttpServerRequest" +import * as HttpServerResponse from "@effect/platform/HttpServerResponse" +import { Effect } from "effect" +import * as Stream from "effect/Stream" +import type { IncomingMessage, Server as HttpServer } from "node:http" +import { createConnection, type Socket } from "node:net" +import { dirname } from "node:path" +import type { Duplex } from "node:stream" +import { type RawData, WebSocket, WebSocketServer } from "ws" + +import type { ProjectBrowserSession, ProjectBrowserStatus } from "../api/contracts.js" +import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js" +import { + browserCdpPort, + browserNoVncPort, + browserVncPort, + parseProjectBrowserProxyPath, + renderExternalUrl, + renderProjectBrowserCdpPath, + renderProjectBrowserNoVncPath, + rewriteCdpVersionPayload, + type ProjectBrowserProxyPath +} from "./project-browser-core.js" +import { getProjectItemById } from "./projects.js" +import { normalizeForwardedPrefix, projectShortKey, rewriteProxyLocation } from "./project-port-proxy-core.js" + +type BrowserApiError = + | ApiBadRequestError + | ApiConflictError + | ApiInternalError + | ApiNotFoundError + +type BrowserContainerState = { + readonly id: string + readonly running: boolean + readonly status: ProjectBrowserStatus +} + +type ContainerNetworkEntry = { + readonly ipAddress: string + readonly name: string +} + +type BrowserProxyUpstream = { + readonly headers: Record + readonly projectId: string + readonly projectKey: string + readonly proxyPath: string + readonly target: ProjectBrowserProxyPath + readonly upstreamOrigin: string + readonly upstreamUrl: URL +} + +type BrowserProjectLookup = { + readonly containerName: string + readonly projectDir: string + readonly projectId: string +} + +type BrowserWebSocketUpstream = + | { + readonly _tag: "Tcp" + readonly host: string + readonly port: number + } + | { + readonly _tag: "WebSocket" + readonly headers: Record + readonly url: string + } + +type PendingWebSocketMessage = { + readonly data: RawData + readonly isBinary: boolean +} + +const dockerOkExit = [0] +const cdpHostHeader = "127.0.0.1:9222" + +const hopByHopRequestHeaders = new Set([ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]) + +const hopByHopResponseHeaders = new Set([ + "connection", + "content-encoding", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade" +]) + +const dockerGitApiContainerName = (): string => process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() || "docker-git-api" + +const dockerCapture = ( + cwd: string, + args: ReadonlyArray, + command: string, + okExitCodes: ReadonlyArray = dockerOkExit +) => + runCommandCapture( + { + args, + command: "docker", + cwd + }, + okExitCodes, + (exitCode) => new CommandFailedError({ command, exitCode }) + ) + +const browserContainerName = (projectContainerName: string): string => `${projectContainerName}-browser` + +const statusFromDockerState = (running: boolean, state: string): ProjectBrowserStatus => { + const normalized = state.trim().toLowerCase() + if (running) { + return "running" + } + if (normalized === "missing") { + return "missing" + } + if (normalized === "created" || normalized === "dead" || normalized === "exited" || normalized === "removing") { + return "stopped" + } + return "unknown" +} + +const parseBrowserContainerState = (output: string): BrowserContainerState => { + const [id = "", rawRunning = "", rawState = ""] = output.trim().split("\t") + const running = rawRunning === "true" + return { + id, + running, + status: statusFromDockerState(running, rawState) + } +} + +const missingBrowserContainerState: BrowserContainerState = { + id: "", + running: false, + status: "missing" +} + +const inspectBrowserContainerState = ( + cwd: string, + containerName: string +) => + dockerCapture( + cwd, + ["inspect", "-f", "{{.Id}}\t{{.State.Running}}\t{{.State.Status}}", containerName], + "docker inspect browser" + ).pipe( + Effect.map(parseBrowserContainerState), + Effect.catchAll(() => Effect.succeed(missingBrowserContainerState)) + ) + +const parseContainerNetworkEntries = (output: string): ReadonlyArray => + output + .trim() + .split(/\r?\n/u) + .flatMap((line) => parseInspectNetworkEntry(line)) + .map(([name, ipAddress]) => ({ name, ipAddress })) + +const inspectContainerNetworks = ( + cwd: string, + containerName: string +) => + dockerCapture( + cwd, + [ + "inspect", + "-f", + String.raw`{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}`, + containerName + ], + "docker inspect browser networks" + ).pipe( + Effect.map(parseContainerNetworkEntries), + Effect.mapError((error) => new ApiInternalError({ message: `Failed to inspect browser networks: ${containerName}`, cause: error })) + ) + +const connectContainerToNetwork = ( + cwd: string, + networkName: string, + containerName: string +) => + networkName === "bridge" + ? Effect.void + : dockerCapture( + cwd, + ["network", "connect", networkName, containerName], + `docker network connect ${networkName}` + ).pipe( + Effect.asVoid, + Effect.orElseSucceed(() => void 0) + ) + +const selectReachableNetwork = ( + entries: ReadonlyArray +): ContainerNetworkEntry | null => entries.find((entry) => entry.name !== "bridge") ?? entries[0] ?? null + +const ensureBrowserReachableIp = ( + cwd: string, + containerName: string +): Effect.Effect => + Effect.gen(function*(_) { + const entries = yield* _(inspectContainerNetworks(cwd, containerName)) + yield* _( + Effect.forEach( + entries.filter((entry) => entry.name !== "bridge"), + (entry) => connectContainerToNetwork(cwd, entry.name, dockerGitApiContainerName()), + { discard: true } + ) + ) + const selected = selectReachableNetwork(entries) + if (selected === null || selected.ipAddress.length === 0) { + return yield* _(Effect.fail(new ApiInternalError({ message: `Browser container has no reachable IP: ${containerName}` }))) + } + return selected.ipAddress + }) + +const resolveProjectByKey = ( + projectKey: string +): Effect.Effect => + Effect.gen(function*(_) { + const index = yield* _(loadProjectIndex()) + if (index === null) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${projectKey}` }))) + } + const matches = index.configPaths + .map((configPath) => ({ configPath, projectDir: dirname(configPath) })) + .filter((project) => projectShortKey(project.projectDir) === projectKey) + if (matches.length === 0) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${projectKey}` }))) + } + if (matches.length > 1) { + return yield* _(Effect.fail(new ApiConflictError({ message: `Project key is ambiguous: ${projectKey}` }))) + } + const match = matches[0] + if (match === undefined) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${projectKey}` }))) + } + const status = yield* _( + loadProjectStatus(match.configPath).pipe( + Effect.mapError((cause) => + new ApiInternalError({ + message: `Failed to load project config for key: ${projectKey}`, + cause + }) + ) + ) + ) + return status === undefined + ? yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${projectKey}` }))) + : { + containerName: status.config.template.containerName, + projectDir: status.projectDir, + projectId: status.projectDir + } + }) + +const browserSessionFromState = ( + projectId: string, + containerName: string, + state: BrowserContainerState, + externalOrigin: string +): ProjectBrowserSession => { + const noVncPath = renderProjectBrowserNoVncPath(projectId) + const cdpPath = renderProjectBrowserCdpPath(projectId) + return { + cdpPath, + cdpUrl: renderExternalUrl(externalOrigin, cdpPath), + containerName, + noVncPath, + noVncUrl: renderExternalUrl(externalOrigin, noVncPath), + projectId, + projectKey: projectShortKey(projectId), + status: state.status + } +} + +export const readProjectBrowserSession = ( + projectId: string, + externalOrigin: string +): Effect.Effect => + Effect.gen(function*(_) { + const project = yield* _(getProjectItemById(projectId)) + const containerName = browserContainerName(project.containerName) + const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName)) + return browserSessionFromState(projectId, containerName, state, externalOrigin) + }) + +const copyProxyRequestHeaders = ( + request: HttpServerRequest.HttpServerRequest, + target: ProjectBrowserProxyPath, + proxyPath: string +): Headers => { + const headers = new Headers() + for (const [key, value] of Object.entries(request.headers)) { + const normalized = key.toLowerCase() + if (typeof value === "string" && !hopByHopRequestHeaders.has(normalized)) { + headers.set(key, value) + } + } + headers.set("accept-encoding", "identity") + headers.set("x-forwarded-prefix", proxyPath) + if (target._tag === "Cdp") { + headers.set("host", cdpHostHeader) + } else if (typeof request.headers["host"] === "string") { + headers.set("x-forwarded-host", request.headers["host"]) + } + return headers +} + +const copyProxyResponseHeaders = ( + response: Response, + proxyPath: string, + upstreamOrigin: string, + externalPrefix: string +): Record => { + const headers: Record = {} + for (const [key, value] of response.headers.entries()) { + const normalized = key.toLowerCase() + if (!hopByHopResponseHeaders.has(normalized)) { + headers[key] = normalized === "location" + ? rewriteProxyLocation(value, proxyPath, upstreamOrigin, externalPrefix) + : value + } + } + headers["cache-control"] = headers["cache-control"] ?? "no-store" + return headers +} + +const hasRequestBody = (method: string): boolean => method !== "GET" && method !== "HEAD" + +const resolveBrowserProxyUpstream = ( + target: ProjectBrowserProxyPath, + requestUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const project = yield* _(resolveProjectByKey(target.projectKey)) + const containerName = browserContainerName(project.containerName) + const state = yield* _(inspectBrowserContainerState(project.projectDir, containerName)) + if (state.status !== "running") { + return yield* _(Effect.fail(new ApiBadRequestError({ message: `Browser container is not running: ${containerName}` }))) + } + const ipAddress = yield* _(ensureBrowserReachableIp(project.projectDir, containerName)) + const port = target._tag === "Cdp" ? browserCdpPort : browserNoVncPort + const proxyPath = target._tag === "Cdp" + ? `/b/${target.projectKey}/cdp/` + : `/b/${target.projectKey}/` + const search = new URL(requestUrl, "http://localhost").search + const upstreamUrl = new URL(`${target.upstreamPath}${search}`, `http://${ipAddress}:${port}`) + return { + headers: target._tag === "Cdp" ? { host: cdpHostHeader } : {}, + projectId: project.projectId, + projectKey: target.projectKey, + proxyPath, + target, + upstreamOrigin: upstreamUrl.origin, + upstreamUrl + } + }) + +const fetchBrowserUpstream = ( + request: HttpServerRequest.HttpServerRequest, + upstream: BrowserProxyUpstream +) => + Effect.gen(function*(_) { + const requestBody = hasRequestBody(request.method) + ? yield* _(request.arrayBuffer) + : undefined + const init = { + headers: copyProxyRequestHeaders(request, upstream.target, upstream.proxyPath), + method: request.method, + redirect: "manual" as const, + ...(requestBody !== undefined && requestBody.byteLength > 0 + ? { body: new Uint8Array(requestBody) } + : {}) + } + return yield* _( + Effect.tryPromise({ + try: () => fetch(upstream.upstreamUrl, init), + catch: (cause) => + new ApiInternalError({ + message: `Failed to proxy browser ${upstream.target._tag}.`, + cause + }) + }) + ) + }) + +const browserRedirectResponse = ( + projectId: string +): HttpServerResponse.HttpServerResponse => + HttpServerResponse.empty({ + headers: { + "cache-control": "no-store", + location: renderProjectBrowserNoVncPath(projectId) + }, + status: 302 + }) + +const readResponseText = ( + response: Response +) => + Effect.tryPromise({ + try: () => response.text(), + catch: (cause) => new ApiInternalError({ message: "Failed to read browser proxy response.", cause }) + }) + +const cdpVersionResponse = ( + response: Response, + upstream: BrowserProxyUpstream, + externalOrigin: string, + headers: Record +) => + readResponseText(response).pipe( + Effect.map((payload) => + HttpServerResponse.setStatus( + HttpServerResponse.text( + rewriteCdpVersionPayload(payload, externalOrigin, upstream.projectId), + { contentType: headers["content-type"] ?? "application/json; charset=utf-8", headers } + ), + response.status + ) + ) + ) + +export const proxyProjectBrowser = ( + request: HttpServerRequest.HttpServerRequest, + target: ProjectBrowserProxyPath, + externalOrigin: string +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + BrowserApiError | HttpServerError.RequestError | PlatformError, + ListProjectsContext +> => + Effect.gen(function*(_) { + const upstream = yield* _(resolveBrowserProxyUpstream(target, request.url)) + if (target._tag === "NoVnc" && target.upstreamPath === "/") { + return browserRedirectResponse(upstream.projectId) + } + const upstreamResponse = yield* _(fetchBrowserUpstream(request, upstream)) + const headers = copyProxyResponseHeaders( + upstreamResponse, + upstream.proxyPath, + upstream.upstreamOrigin, + normalizeForwardedPrefix(request.headers["x-forwarded-prefix"]) + ) + if (target._tag === "Cdp" && target.upstreamPath === "/json/version" && upstreamResponse.body !== null) { + return yield* _(cdpVersionResponse(upstreamResponse, upstream, externalOrigin, headers)) + } + if (request.method === "HEAD" || upstreamResponse.body === null) { + return HttpServerResponse.empty({ headers, status: upstreamResponse.status }) + } + return HttpServerResponse.stream( + Stream.fromReadableStream( + () => upstreamResponse.body as ReadableStream, + (cause) => new ApiInternalError({ message: "Failed to read browser proxy body.", cause }) + ), + { + headers, + status: upstreamResponse.status, + statusText: upstreamResponse.statusText + } + ) + }) + +const resolveBrowserWebSocketUpstream = ( + request: IncomingMessage +): Effect.Effect => { + const url = request.url + if (url === undefined) { + return Effect.succeed(null) + } + const parsed = new URL(url, "http://localhost") + const target = parseProjectBrowserProxyPath(parsed.pathname) + if (target === null) { + return Effect.succeed(null) + } + if (target._tag === "NoVnc" && target.upstreamPath !== "/websockify") { + return Effect.succeed(null) + } + return resolveBrowserProxyUpstream(target, url).pipe( + Effect.map((upstream) => { + if (target._tag === "NoVnc") { + return { + _tag: "Tcp" as const, + host: upstream.upstreamUrl.hostname, + port: browserVncPort + } + } + const wsUrl = new URL(upstream.upstreamUrl.toString()) + wsUrl.protocol = "ws:" + return { + _tag: "WebSocket" as const, + headers: upstream.headers, + url: wsUrl.toString() + } + }) + ) +} + +const denyUpgrade = (socket: Duplex): void => { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n") + socket.destroy() +} + +const firstHeader = (value: string | ReadonlyArray | undefined): string | undefined => + typeof value === "string" ? value : value?.[0] + +const parseWebSocketProtocols = (request: IncomingMessage): Array => { + const header = firstHeader(request.headers["sec-websocket-protocol"]) + if (header === undefined) { + return [] + } + return header + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) +} + +const connectUpstreamWebSocket = ( + request: IncomingMessage, + target: Extract +): WebSocket => { + const protocols = parseWebSocketProtocols(request) + const options = { headers: target.headers } + return protocols.length === 0 + ? new WebSocket(target.url, options) + : new WebSocket(target.url, protocols, options) +} + +const rawDataToBuffer = (data: RawData): Buffer => + Array.isArray(data) + ? Buffer.concat(data) + : Buffer.isBuffer(data) + ? data + : Buffer.from(data) + +const bridgeSocketToTcp = ( + clientSocket: WebSocket, + upstream: Socket +): void => { + const pending: Array = [] + const writeWhenOpen = (data: Buffer): void => { + if (upstream.readyState === "open") { + upstream.write(data) + return + } + pending.push(data) + } + const flushPending = (): void => { + for (const message of pending.splice(0)) { + upstream.write(message) + } + } + clientSocket.on("message", (data) => { + writeWhenOpen(rawDataToBuffer(data)) + }) + upstream.on("connect", flushPending) + upstream.on("data", (data) => { + if (clientSocket.readyState === WebSocket.OPEN) { + clientSocket.send(data, { binary: true }) + } + }) + clientSocket.on("close", () => { + upstream.destroy() + }) + upstream.on("close", () => { + clientSocket.close() + }) + upstream.on("error", () => { + clientSocket.close() + }) +} + +const bridgeSockets = ( + clientSocket: WebSocket, + upstream: WebSocket +): void => { + const pending: Array = [] + const sendWhenOpen = (socket: WebSocket, data: RawData, isBinary: boolean): void => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(data, { binary: isBinary }) + } + } + const flushPending = (): void => { + for (const message of pending.splice(0)) { + sendWhenOpen(upstream, message.data, message.isBinary) + } + } + clientSocket.on("message", (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + sendWhenOpen(upstream, data, isBinary) + return + } + pending.push({ data, isBinary }) + }) + upstream.on("message", (data, isBinary) => { + sendWhenOpen(clientSocket, data, isBinary) + }) + upstream.on("open", () => { + flushPending() + }) + clientSocket.on("close", () => { + upstream.close() + }) + upstream.on("close", () => { + clientSocket.close() + }) + upstream.on("error", () => { + clientSocket.close() + }) +} + +const connectBrowserWebSocket = ( + webSocketServer: WebSocketServer, + request: IncomingMessage, + socket: Duplex, + head: Buffer, + target: BrowserWebSocketUpstream +): void => { + webSocketServer.handleUpgrade(request, socket, head, (clientSocket) => { + if (target._tag === "Tcp") { + const upstream = createConnection({ host: target.host, port: target.port }) + bridgeSocketToTcp(clientSocket, upstream) + return + } + const upstream = connectUpstreamWebSocket(request, target) + upstream.on("error", () => { + clientSocket.close() + }) + upstream.on("close", () => { + clientSocket.close() + }) + try { + bridgeSockets(clientSocket, upstream) + } catch { + clientSocket.close() + upstream.close() + } + }) +} + +export const attachProjectBrowserWebSocketServer = (server: HttpServer): void => { + const webSocketServer = new WebSocketServer({ noServer: true }) + server.on("upgrade", (request, socket, head) => { + const parsed = new URL(request.url ?? "/", "http://localhost") + if (parseProjectBrowserProxyPath(parsed.pathname) === null) { + return + } + Effect.runFork( + resolveBrowserWebSocketUpstream(request).pipe( + Effect.provide(NodeContext.layer), + Effect.match({ + onFailure: () => { + denyUpgrade(socket) + }, + onSuccess: (target) => { + if (target === null) { + denyUpgrade(socket) + return + } + connectBrowserWebSocket(webSocketServer, request, socket, head, target) + } + }) + ) + ) + }) +} diff --git a/packages/api/tests/project-port-forward-core.test.ts b/packages/api/tests/project-port-forward-core.test.ts index 3d4ad84e..9c57dc47 100644 --- a/packages/api/tests/project-port-forward-core.test.ts +++ b/packages/api/tests/project-port-forward-core.test.ts @@ -9,6 +9,14 @@ import { rowToProjectPortForward, selectHostPort } from "../src/services/project-port-forward-core.js" +import { + parseProjectBrowserProxyPath, + renderProjectBrowserCdpPath, + renderProjectBrowserNoVncPath, + renderProjectBrowserProxyPath, + rewriteCdpVersionPayload, + rewriteCdpWebSocketUrl +} from "../src/services/project-browser-core.js" import { parseLinuxDefaultGatewayIp, parseProjectPortProxyPath, @@ -98,6 +106,54 @@ describe("project port forward core", () => { expect(renderLegacyForwardProxyPath("a/b", 5173)).toBe("/projects/a%2Fb/ports/5173/proxy/") })) + it.effect("parses project browser proxy paths", () => + Effect.sync(() => { + const key = projectShortKey("a/b") + + expect(parseProjectBrowserProxyPath(`/b/${key}/vnc.html`)).toEqual({ + _tag: "NoVnc", + projectKey: key, + upstreamPath: "/vnc.html" + }) + expect(parseProjectBrowserProxyPath(`/b/${key}/websockify`)).toEqual({ + _tag: "NoVnc", + projectKey: key, + upstreamPath: "/websockify" + }) + expect(parseProjectBrowserProxyPath(`/b/${key}/cdp/json/version`)).toEqual({ + _tag: "Cdp", + projectKey: key, + upstreamPath: "/json/version" + }) + expect(renderProjectBrowserProxyPath("a/b")).toBe(`/b/${key}/`) + expect(renderProjectBrowserCdpPath("a/b")).toBe(`/b/${key}/cdp/json/version`) + expect(renderProjectBrowserNoVncPath("a/b")).toContain(`/b/${key}/vnc.html?`) + expect(renderProjectBrowserNoVncPath("a/b")).toContain(`path=b%2F${key}%2Fwebsockify`) + })) + + it.effect("rewrites CDP websocket URLs into browser proxy paths", () => + Effect.sync(() => { + const key = projectShortKey("a/b") + expect( + rewriteCdpWebSocketUrl( + "ws://127.0.0.1:9222/devtools/browser/abc", + "https://docker-git.example.test", + "a/b" + ) + ).toBe(`wss://docker-git.example.test/b/${key}/cdp/devtools/browser/abc`) + + expect( + rewriteCdpVersionPayload( + JSON.stringify({ Browser: "Chrome", webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/abc" }), + "http://localhost:4191", + "a/b" + ) + ).toBe(JSON.stringify({ + Browser: "Chrome", + webSocketDebuggerUrl: `ws://localhost:4191/b/${key}/cdp/devtools/browser/abc` + })) + })) + it.effect("parses Linux default gateway route", () => Effect.sync(() => { const route = [ diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts index 794b260f..fc4efc7b 100644 --- a/packages/api/tests/schema.test.ts +++ b/packages/api/tests/schema.test.ts @@ -11,6 +11,7 @@ import { CreateProjectRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema, + ProjectBrowserSessionSchema, StateCommitRequestSchema, StateInitRequestSchema, StateSyncRequestSchema, @@ -268,6 +269,30 @@ describe("api schemas", () => { }) })) + it.effect("decodes project browser session payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(ProjectBrowserSessionSchema)({ + cdpPath: "/b/abc123abc123/cdp/json/version", + cdpUrl: "https://docker-git.example.test/b/abc123abc123/cdp/json/version", + containerName: "dg-project-browser", + noVncPath: "/b/abc123abc123/vnc.html?autoconnect=true", + noVncUrl: "https://docker-git.example.test/b/abc123abc123/vnc.html?autoconnect=true", + projectId: "project-1", + projectKey: "abc123abc123", + status: "running" + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.status).toBe("running") + expect(value.containerName).toBe("dg-project-browser") + } + }) + })) + it.effect("decodes terminal session payload", () => Effect.sync(() => { const result = Schema.decodeUnknownEither(TerminalSessionSchema)({ diff --git a/packages/app/scripts/serve-dist-web.mjs b/packages/app/scripts/serve-dist-web.mjs index 8ecbcfbb..6664d75b 100644 --- a/packages/app/scripts/serve-dist-web.mjs +++ b/packages/app/scripts/serve-dist-web.mjs @@ -59,6 +59,53 @@ const resolveUpstreamPath = (url) => { return `${pathname}${parsed.search}` } +const firstHeader = (value) => Array.isArray(value) ? value[0] : value + +const proxyForwardHeaders = (request, forwardedPrefix) => { + const forwardedHost = firstHeader(request.headers["x-forwarded-host"]) ?? request.headers.host + const forwardedProto = firstHeader(request.headers["x-forwarded-proto"]) ?? "http" + return { + ...request.headers, + host: `${apiHost}:${apiPort}`, + ...(forwardedHost === undefined ? {} : { "x-forwarded-host": forwardedHost }), + "x-forwarded-prefix": forwardedPrefix, + "x-forwarded-proto": forwardedProto + } +} + +const parseWebSocketProtocols = (value) => { + const header = firstHeader(value) + if (header === undefined) { + return [] + } + return header + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) +} + +const proxyWebSocketForwardHeaders = (request, forwardedPrefix) => { + const forwardedHost = firstHeader(request.headers["x-forwarded-host"]) ?? request.headers.host + const forwardedProto = firstHeader(request.headers["x-forwarded-proto"]) ?? "http" + return { + ...(forwardedHost === undefined ? {} : { "x-forwarded-host": forwardedHost }), + "x-forwarded-prefix": forwardedPrefix, + "x-forwarded-proto": forwardedProto + } +} + +const connectUpstreamWebSocket = ( + url, + request, + forwardedPrefix +) => { + const protocols = parseWebSocketProtocols(request.headers["sec-websocket-protocol"]) + const options = { headers: proxyWebSocketForwardHeaders(request, forwardedPrefix) } + return protocols.length === 0 + ? new WebSocket(url, options) + : new WebSocket(url, protocols, options) +} + const proxyHttp = ( request, response @@ -66,7 +113,7 @@ const proxyHttp = ( const forwardedPrefix = request.url?.startsWith("/api/") ? "/api" : "" const upstream = httpRequest( { - headers: { ...request.headers, host: `${apiHost}:${apiPort}`, "x-forwarded-prefix": forwardedPrefix }, + headers: proxyForwardHeaders(request, forwardedPrefix), host: apiHost, method: request.method, path: resolveUpstreamPath(request.url ?? "/"), @@ -94,9 +141,43 @@ const proxyHttp = ( const webSocketServer = new WebSocketServer({ noServer: true }) +const bridgeWebSockets = (clientSocket, upstream) => { + const pending = [] + const sendWhenOpen = (socket, data, isBinary) => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(data, { binary: isBinary }) + } + } + const flushPending = () => { + for (const message of pending.splice(0)) { + sendWhenOpen(upstream, message.data, message.isBinary) + } + } + clientSocket.on("message", (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + sendWhenOpen(upstream, data, isBinary) + return + } + pending.push({ data, isBinary }) + }) + clientSocket.on("close", () => { + upstream.close() + }) + upstream.on("open", flushPending) + upstream.on("message", (data, isBinary) => { + sendWhenOpen(clientSocket, data, isBinary) + }) + upstream.on("close", () => { + clientSocket.close() + }) + upstream.on("error", () => { + clientSocket.close() + }) +} + const server = createServer((request, response) => { const parsed = new URL(request.url ?? "/", "http://localhost") - if (parsed.pathname.startsWith("/api/") || parsed.pathname.startsWith("/p/")) { + if (parsed.pathname.startsWith("/api/") || parsed.pathname.startsWith("/p/") || parsed.pathname.startsWith("/b/")) { proxyHttp(request, response) return } @@ -117,35 +198,21 @@ const server = createServer((request, response) => { server.on("upgrade", (request, socket, head) => { const parsed = new URL(request.url ?? "/", "http://localhost") - if (!parsed.pathname.startsWith("/api/") || !parsed.pathname.endsWith("/ws")) { + const terminalWebSocket = parsed.pathname.startsWith("/api/") && parsed.pathname.endsWith("/ws") + const browserWebSocket = parsed.pathname.startsWith("/b/") + if (!terminalWebSocket && !browserWebSocket) { socket.destroy() return } - const upstream = new WebSocket(`ws://${apiHost}:${apiPort}${resolveUpstreamPath(request.url ?? "/")}`) - - upstream.on("open", () => { - webSocketServer.handleUpgrade(request, socket, head, (clientSocket) => { - clientSocket.on("message", (data, isBinary) => { - upstream.send(data, { binary: isBinary }) - }) - clientSocket.on("close", () => { - upstream.close() - }) - upstream.on("message", (data, isBinary) => { - clientSocket.send(data, { binary: isBinary }) - }) - upstream.on("close", () => { - clientSocket.close() - }) - upstream.on("error", () => { - clientSocket.close() - }) - }) - }) - - upstream.on("error", () => { - socket.destroy() + webSocketServer.handleUpgrade(request, socket, head, (clientSocket) => { + const forwardedPrefix = request.url?.startsWith("/api/") ? "/api" : "" + const upstream = connectUpstreamWebSocket( + `ws://${apiHost}:${apiPort}${resolveUpstreamPath(request.url ?? "/")}`, + request, + forwardedPrefix + ) + bridgeWebSockets(clientSocket, upstream) }) }) diff --git a/packages/app/src/web/actions-browser.ts b/packages/app/src/web/actions-browser.ts new file mode 100644 index 00000000..01f3cf53 --- /dev/null +++ b/packages/app/src/web/actions-browser.ts @@ -0,0 +1,74 @@ +import { type BrowserActionContext, requireSelectedProjectId, withBusy } from "./actions-shared.js" +import { loadProjectBrowser, projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession } from "./api.js" + +const openUrl = (url: string): boolean => { + if (typeof globalThis.open === "function") { + const openedWindow = globalThis.open(url, "_blank", "noopener") + return openedWindow !== null + } + return false +} + +const browserStatusMessage = (browser: ProjectBrowserSession): string => + browser.status === "running" + ? `Browser is available at ${projectBrowserNoVncUrl(browser)}.` + : `Browser sidecar is ${browser.status} for ${browser.projectKey}.` + +export const loadProjectBrowserById = ( + projectId: string, + context: BrowserActionContext, + options?: { readonly silent?: boolean } +) => { + withBusy({ + context, + effect: loadProjectBrowser(projectId), + label: "Loading project browser", + onSuccess: (browser) => { + context.setProjectBrowser(browser) + if (options?.silent !== true) { + context.setMessage(browserStatusMessage(browser)) + } + } + }) +} + +export const loadSelectedProjectBrowser = ( + context: BrowserActionContext, + options?: { readonly silent?: boolean } +) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + context.setProjectBrowser(null) + return + } + loadProjectBrowserById(projectId, context, options) +} + +export const openSelectedProjectBrowser = (context: BrowserActionContext) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + return + } + openProjectBrowserById(projectId, context) +} + +export const openProjectBrowserById = (projectId: string, context: BrowserActionContext) => { + withBusy({ + context, + effect: loadProjectBrowser(projectId), + label: "Opening project browser", + onSuccess: (browser) => { + context.setProjectBrowser(browser) + if (browser.status !== "running") { + context.setMessage(`Browser sidecar is ${browser.status}. Enable Playwright MCP and start the project first.`) + return + } + const noVncUrl = projectBrowserNoVncUrl(browser) + context.setMessage( + openUrl(noVncUrl) + ? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` + : `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` + ) + } + }) +} diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index fd26b806..16e5f196 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -1,12 +1,14 @@ import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js" import type { CreateInputs } from "../docker-git/menu-types.js" +import { openSelectedProjectBrowser } from "./actions-browser.js" import { openSelectedProjectPort } from "./actions-port-forwards.js" import { type BrowserActionContext, confirmAction, projectActionLabel, requireSelectedProjectId, - withBusy + withBusy, + withSelectedProjectBusy } from "./actions-shared.js" import { createProject, @@ -43,15 +45,13 @@ export const loadSelectedProjectInfo = ( readonly silent?: boolean } ) => { - const projectId = requireSelectedProjectId(context) - if (projectId === null) { - context.setSelectedProject(null) - return - } - withBusy({ + withSelectedProjectBusy({ context, - effect: loadProjectDetails(projectId), + effect: loadProjectDetails, label: "Loading project info", + onMissing: () => { + context.setSelectedProject(null) + }, onSuccess: (project) => { context.setSelectedProject(project) if (options?.silent !== true) { @@ -122,6 +122,8 @@ export const connectProjectById = ( const encodedProjectId = encodeURIComponent(project.id) const encodedSessionId = encodeURIComponent(session.id) context.setTerminalSession({ + browserProjectId: project.id, + browserProjectName: project.displayName, closePath: `/projects/${encodedProjectId}/terminal-sessions/${encodedSessionId}`, exitMessage: "SSH session ended.", header: `SSH terminal: ${project.displayName}`, @@ -231,11 +233,15 @@ export const runProjectMenuAction = ( openSelectedProjectPort(context) return } + if (currentMenu === "Browser") { + openSelectedProjectBrowser(context) + return + } runProjectMenuCommand(currentMenu, context) } const runProjectMenuCommand = ( - currentMenu: Exclude, + currentMenu: Exclude, context: BrowserActionContext ) => { if (currentMenu === "Status") { diff --git a/packages/app/src/web/actions-shared.ts b/packages/app/src/web/actions-shared.ts index 00c3881f..21413eda 100644 --- a/packages/app/src/web/actions-shared.ts +++ b/packages/app/src/web/actions-shared.ts @@ -3,7 +3,14 @@ import type { Dispatch, SetStateAction } from "react" import type { ActionPromptState } from "./action-prompt.js" import { createAuthActionPrompt } from "./action-prompt.js" -import type { AuthSnapshot, GithubAuthStatus, ProjectAuthSnapshot, ProjectDetails, ProjectPortForward } from "./api.js" +import type { + AuthSnapshot, + GithubAuthStatus, + ProjectAuthSnapshot, + ProjectBrowserSession, + ProjectDetails, + ProjectPortForward +} from "./api.js" import { githubAuthGateMessage, shouldRequireGithubAuth } from "./github-auth-gate.js" import { browserMenuIndex } from "./menu.js" import type { BrowserScreen } from "./screen.js" @@ -21,6 +28,14 @@ type BusyAction = { readonly onSuccess: (value: A) => void } +type SelectedProjectBusyAction = { + readonly context: BrowserActionContext + readonly effect: (projectId: string) => Effect.Effect + readonly label: string + readonly onMissing: () => void + readonly onSuccess: (value: A) => void +} + type AuthSuccessState = { readonly githubStatus: GithubAuthStatus readonly message: string @@ -45,6 +60,7 @@ export type BrowserActionContext = { readonly setPortForwardInput: Setter readonly setPortForwards: Setter> readonly setProjectAuthSnapshot: Setter + readonly setProjectBrowser: Setter readonly setSelectedMenuIndex: Setter readonly setSelectedProject: Setter readonly setSelectedProjectId: Setter @@ -112,6 +128,22 @@ export const requireSelectedProjectId = ( return null } +export const withSelectedProjectBusy = ( + { context, effect, label, onMissing, onSuccess }: SelectedProjectBusyAction +) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + onMissing() + return + } + withBusy({ + context, + effect: effect(projectId), + label, + onSuccess + }) +} + export const requireGithubAuthConfigured = (context: BrowserActionContext): boolean => { if (context.githubStatus === null) { context.setSelectedMenuIndex(browserMenuIndex("Auth")) diff --git a/packages/app/src/web/actions.ts b/packages/app/src/web/actions.ts index 6d17fbae..fbbbff1b 100644 --- a/packages/app/src/web/actions.ts +++ b/packages/app/src/web/actions.ts @@ -14,6 +14,12 @@ export { runBrowserProjectAuthAction, submitBrowserActionPrompt } from "./actions-auth.js" +export { + loadProjectBrowserById, + loadSelectedProjectBrowser, + openProjectBrowserById, + openSelectedProjectBrowser +} from "./actions-browser.js" export { closeSelectedProjectPort, loadSelectedProjectPorts, openSelectedProjectPort } from "./actions-port-forwards.js" export { loadSelectedProjectInfo } from "./actions-projects.js" diff --git a/packages/app/src/web/api-schema.ts b/packages/app/src/web/api-schema.ts index faece990..cfb6c77c 100644 --- a/packages/app/src/web/api-schema.ts +++ b/packages/app/src/web/api-schema.ts @@ -88,6 +88,26 @@ export const ProjectPortForwardResponseSchema = Schema.Struct({ forward: ProjectPortForwardSchema }) +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: Schema.Union( + Schema.Literal("running"), + Schema.Literal("stopped"), + Schema.Literal("missing"), + Schema.Literal("unknown") + ) +}) + +export const ProjectBrowserResponseSchema = Schema.Struct({ + browser: ProjectBrowserSessionSchema +}) + export const OutputResponseSchema = Schema.Struct({ output: Schema.String }) @@ -191,6 +211,7 @@ export const ProjectEventsPollResponseSchema = Schema.Struct({ export type ProjectSummary = Schema.Schema.Type export type ProjectDetails = Schema.Schema.Type export type ProjectPortForward = Schema.Schema.Type +export type ProjectBrowserSession = Schema.Schema.Type export type GithubAuthStatus = Schema.Schema.Type export type AuthSnapshot = Schema.Schema.Type export type ProjectAuthSnapshot = Schema.Schema.Type diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index fbd5e417..036631a7 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -9,6 +9,7 @@ import { HealthResponseSchema, OutputResponseSchema, ProjectAuthSnapshotResponseSchema, + ProjectBrowserResponseSchema, ProjectEventsPollResponseSchema, ProjectPortForwardResponseSchema, ProjectPortForwardsResponseSchema, @@ -21,6 +22,7 @@ import type { CreateProjectDraft, DashboardData, ProjectAuthFlow, + ProjectBrowserSession, ProjectPortForward } from "./api-schema.js" @@ -33,6 +35,7 @@ export type { GithubAuthStatus, ProjectAuthFlow, ProjectAuthSnapshot, + ProjectBrowserSession, ProjectDetails, ProjectPortForward, ProjectSummary, @@ -42,6 +45,10 @@ export type { export const projectPortForwardProxyUrl = (forward: ProjectPortForward): string => `${resolveApiBaseUrl()}${forward.proxyPath}` +export const projectBrowserNoVncUrl = (browser: ProjectBrowserSession): string => browser.noVncPath + +export const projectBrowserCdpUrl = (browser: ProjectBrowserSession): string => browser.cdpPath + export const loadDashboard = (): Effect.Effect => Effect.all({ health: requestJson("GET", "/health", HealthResponseSchema), @@ -74,6 +81,11 @@ export const loadProjectPortForwards = (projectId: string) => Effect.map((response) => response.forwards) ) +export const loadProjectBrowser = (projectId: string) => + requestJson("GET", `/projects/${encodeURIComponent(projectId)}/browser`, ProjectBrowserResponseSchema).pipe( + Effect.map((response) => response.browser) + ) + export const createProjectPortForward = ( projectId: string, targetPort: number, diff --git a/packages/app/src/web/app-ready-actions.ts b/packages/app/src/web/app-ready-actions.ts index dc7dac2d..bacc628c 100644 --- a/packages/app/src/web/app-ready-actions.ts +++ b/packages/app/src/web/app-ready-actions.ts @@ -21,6 +21,7 @@ type ActionContextArgs = { readonly setPortForwardInput: BrowserActionContext["setPortForwardInput"] readonly setPortForwards: BrowserActionContext["setPortForwards"] readonly setProjectAuthSnapshot: BrowserActionContext["setProjectAuthSnapshot"] + readonly setProjectBrowser: BrowserActionContext["setProjectBrowser"] readonly setSelectedMenuIndex: BrowserActionContext["setSelectedMenuIndex"] readonly setSelectedProject: BrowserActionContext["setSelectedProject"] readonly setSelectedProjectId: BrowserActionContext["setSelectedProjectId"] @@ -46,6 +47,7 @@ export const createActionContext = (args: ActionContextArgs): BrowserActionConte setPortForwardInput: args.setPortForwardInput, setPortForwards: args.setPortForwards, setProjectAuthSnapshot: args.setProjectAuthSnapshot, + setProjectBrowser: args.setProjectBrowser, setSelectedMenuIndex: args.setSelectedMenuIndex, setSelectedProject: args.setSelectedProject, setSelectedProjectId: args.setSelectedProjectId, diff --git a/packages/app/src/web/app-ready-browser-hook.ts b/packages/app/src/web/app-ready-browser-hook.ts new file mode 100644 index 00000000..3f63fde1 --- /dev/null +++ b/packages/app/src/web/app-ready-browser-hook.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from "react" + +import { loadProjectBrowserById, loadSelectedProjectBrowser } from "./actions.js" +import type { BrowserActionContext } from "./actions.js" +import type { ProjectBrowserSession } from "./api.js" +import type { BrowserMenuTag } from "./menu.js" +import type { BrowserScreen } from "./screen.js" +import type { ActiveTerminalSession } from "./terminal.js" + +type BrowserPanelAutoloadArgs = { + readonly activeScreen: BrowserScreen + readonly context: BrowserActionContext + readonly currentMenu: BrowserMenuTag + readonly selectedProjectId: string | null +} + +type TerminalBrowserAutoloadArgs = { + readonly context: BrowserActionContext + readonly dashboardRefreshTick: number + readonly terminalSession: ActiveTerminalSession | null +} + +export const useProjectBrowserReset = ( + selectedProjectId: string | null, + setProjectBrowser: (value: ProjectBrowserSession | null) => void +) => { + useEffect(() => { + setProjectBrowser(null) + }, [selectedProjectId, setProjectBrowser]) +} + +export const maybeLoadProjectBrowser = ( + { activeScreen, context, currentMenu, selectedProjectId }: BrowserPanelAutoloadArgs +): void => { + if (activeScreen.tag === "ProjectPicker" && currentMenu === "Browser" && selectedProjectId !== null) { + loadSelectedProjectBrowser(context, { silent: true }) + } +} + +export const useTerminalBrowserAutoload = ( + { context, dashboardRefreshTick, terminalSession }: TerminalBrowserAutoloadArgs +) => { + const contextRef = useRef(context) + contextRef.current = context + + useEffect(() => { + const projectId = terminalSession?.browserProjectId + if (projectId === undefined) { + return + } + loadProjectBrowserById(projectId, contextRef.current, { silent: true }) + }, [dashboardRefreshTick, terminalSession?.browserProjectId]) +} diff --git a/packages/app/src/web/app-ready-browser-openable.ts b/packages/app/src/web/app-ready-browser-openable.ts new file mode 100644 index 00000000..11922bbe --- /dev/null +++ b/packages/app/src/web/app-ready-browser-openable.ts @@ -0,0 +1,20 @@ +import type { ProjectBrowserSession } from "./api.js" +import type { BrowserMenuTag } from "./menu.js" + +export const browserSidecarUnavailableMessage = + "Browser sidecar is not running. Enable Playwright MCP and start the project first." + +export const canOpenProjectBrowser = ( + projectBrowser: ProjectBrowserSession | null, + projectId: string | null | undefined +): boolean => + projectId !== null && + projectId !== undefined && + projectBrowser?.projectId === projectId && + projectBrowser.status === "running" + +export const canRunProjectBrowserAction = ( + menu: BrowserMenuTag, + projectBrowser: ProjectBrowserSession | null, + projectId: string | null | undefined +): boolean => menu !== "Browser" || canOpenProjectBrowser(projectBrowser, projectId) diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 23dcd8c0..bb0b8e78 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -2,9 +2,11 @@ import { updateActionPromptValue } from "./action-prompt.js" import { cancelBrowserActionPrompt, closeSelectedProjectPort, + loadSelectedProjectBrowser, loadSelectedProjectPorts, + openProjectBrowserById, + openSelectedProjectBrowser, openSelectedProjectPort, - runBrowserMenuAction, submitBrowserActionPrompt } from "./actions.js" import type { DashboardData } from "./api.js" @@ -14,6 +16,7 @@ import { runAuthActionByIndex, runProjectAuthActionByIndex } from "./app-ready-actions.js" +import { useProjectBrowserReset, useTerminalBrowserAutoload } from "./app-ready-browser-hook.js" import { useBrowserShortcuts } from "./app-ready-browser-shortcuts-hook.js" import { cancelCreate, setCreateBuffer, submitCreateView, useCreateMenuReset } from "./app-ready-create.js" import { useGithubAuthGate } from "./app-ready-github-auth-gate-hook.js" @@ -27,8 +30,9 @@ import { useReadyState } from "./app-ready-hooks.js" import { useProjectPortForwardsReset } from "./app-ready-port-forwards-hook.js" +import { bindScreenActions } from "./app-ready-screen-actions.js" import { useSshLink } from "./app-ready-ssh-link-hook.js" -import { isProjectMenu, menuScreen, outputScreen, projectPickerScreen, screenForMenu } from "./screen.js" +import { useReadyUrlSync } from "./app-ready-url.js" type ReadyControllerArgs = { readonly dashboard: DashboardData @@ -69,6 +73,7 @@ const useReadyResetEffects = (args: ReadySideEffectsArgs) => { }) useProjectNavigationReset(args.currentMenu, args.state.setProjectNavigationArmed) useProjectAuthReset(args.state.selectedProjectId, args.state.setProjectAuthSnapshot) + useProjectBrowserReset(args.state.selectedProjectId, args.state.setProjectBrowser) useProjectDetailsReset(args.state.selectedProjectId, args.state.setSelectedProject) useProjectPortForwardsReset( args.state.selectedProjectId, @@ -78,6 +83,11 @@ const useReadyResetEffects = (args: ReadySideEffectsArgs) => { } const useReadyAutoloadEffects = (args: ReadySideEffectsArgs) => { + useTerminalBrowserAutoload({ + context: args.actionContext, + dashboardRefreshTick: args.dashboardRefreshTick, + terminalSession: args.state.terminalSession + }) usePanelAutoload({ activeScreen: args.state.activeScreen, authSnapshot: args.state.authSnapshot, @@ -103,6 +113,7 @@ const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => { createView: args.state.createView, currentMenu: args.currentMenu, dashboard: args.dashboard, + projectBrowser: args.state.projectBrowser, selectedProjectId: args.state.selectedProjectId, setCreateView: args.state.setCreateView, setActiveScreen: args.state.setActiveScreen, @@ -114,6 +125,11 @@ const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => { } const useReadySideEffects = (args: ReadySideEffectsArgs) => { + useReadyUrlSync({ + currentMenu: args.currentMenu, + dashboard: args.dashboard, + state: args.state + }) useProjectSyncEffects(args) useReadyResetEffects(args) useSshLink({ @@ -194,51 +210,17 @@ const bindPortForwardActions = ( } }) -const bindScreenActions = ( - actionContext: ReturnType, - dashboard: DashboardData, - state: ReturnType +const bindBrowserActions = ( + actionContext: ReturnType ) => ({ - onBackScreen: () => { - if (state.activeScreen.tag === "Create") { - cancelCreate(actionContext, state.setCreateView) - return - } - if (state.activeScreen.tag === "ProjectAuth" || state.activeScreen.tag === "Output") { - state.setActiveScreen( - isProjectMenu(resolveCurrentMenu(state.selectedMenuIndex)) ? projectPickerScreen() : menuScreen() - ) - return - } - state.setProjectNavigationArmed(false) - state.setActiveScreen(menuScreen()) + onOpenProjectBrowserById: (projectId: string) => { + openProjectBrowserById(projectId, actionContext) }, - onOpenMenuScreen: (index: number) => { - const menu = resolveCurrentMenu(index) - state.setSelectedMenuIndex(index) - if (menu === "DownAll" || menu === "Quit") { - runBrowserMenuAction(menu, actionContext) - return - } - state.setActiveScreen(screenForMenu(menu)) - if (isProjectMenu(menu)) { - state.setProjectNavigationArmed(true) - state.setSelectedProjectId((projectId) => projectId ?? dashboard.projects[0]?.id ?? null) - } + onOpenProjectBrowser: () => { + openSelectedProjectBrowser(actionContext) }, - onRunCurrentMenuAction: () => { - const menu = resolveCurrentMenu(state.selectedMenuIndex) - if (menu === "ProjectAuth") { - state.setActiveScreen({ tag: "ProjectAuth" }) - runBrowserMenuAction(menu, actionContext) - return - } - if (menu === "Logs" || menu === "Status") { - state.setActiveScreen(outputScreen()) - runBrowserMenuAction(menu, actionContext) - return - } - runBrowserMenuAction(menu, actionContext) + onRefreshProjectBrowser: () => { + loadSelectedProjectBrowser(actionContext) } }) @@ -262,6 +244,7 @@ export const useReadyController = ({ dashboard, dashboardRefreshTick, refreshDas setPortForwardInput: state.setPortForwardInput, setPortForwards: state.setPortForwards, setProjectAuthSnapshot: state.setProjectAuthSnapshot, + setProjectBrowser: state.setProjectBrowser, setSelectedMenuIndex: state.setSelectedMenuIndex, setSelectedProject: state.setSelectedProject, setSelectedProjectId: state.setSelectedProjectId, @@ -274,6 +257,7 @@ export const useReadyController = ({ dashboard, dashboardRefreshTick, refreshDas ...bindCreateActions(actionContext, dashboard, state), ...bindActionPromptActions(actionContext, state), ...bindPortForwardActions(actionContext, state), + ...bindBrowserActions(actionContext), ...bindScreenActions(actionContext, dashboard, state), currentMenu, selectedProjectSummary, diff --git a/packages/app/src/web/app-ready-hooks.ts b/packages/app/src/web/app-ready-hooks.ts index 0c3954dd..5e930014 100644 --- a/packages/app/src/web/app-ready-hooks.ts +++ b/packages/app/src/web/app-ready-hooks.ts @@ -13,9 +13,11 @@ import type { DashboardData, GithubAuthStatus, ProjectAuthSnapshot, + ProjectBrowserSession, ProjectDetails, ProjectPortForward } from "./api.js" +import { maybeLoadProjectBrowser } from "./app-ready-browser-hook.js" import { resetCreateView } from "./app-ready-create.js" import { maybeLoadProjectPortForwards, usePortForwardState } from "./app-ready-port-forwards-hook.js" import { @@ -62,6 +64,7 @@ type ReadyStateSetters = Pick< | "setPortForwardInput" | "setPortForwards" | "setProjectAuthSnapshot" + | "setProjectBrowser" | "setSelectedMenuIndex" | "setSelectedProject" | "setSelectedProjectId" @@ -81,6 +84,7 @@ export type ReadyState = ReadyStateSetters & { readonly project: ProjectDetails | null readonly projectNavigationArmed: boolean readonly projectAuthSnapshot: ProjectAuthSnapshot | null + readonly projectBrowser: ProjectBrowserSession | null readonly setActionPrompt: Setter readonly setActiveScreen: Setter readonly setCreateView: Setter @@ -91,53 +95,80 @@ export type ReadyState = ReadyStateSetters & { readonly terminalSession: ActiveTerminalSession | null } -export const useReadyState = (): ReadyState => { +const useReadyNavigationState = () => { const [selectedMenuIndex, setSelectedMenuIndex] = useState(0) const [activeScreen, setActiveScreen] = useState(menuScreen) const [selectedProjectId, setSelectedProjectId] = useState(null) - const portForwardState = usePortForwardState() + + return { + activeScreen, + selectedMenuIndex, + selectedProjectId, + setActiveScreen, + setSelectedMenuIndex, + setSelectedProjectId + } +} + +const useReadyPanelState = () => { const [actionPrompt, setActionPrompt] = useState(null) - const [project, setSelectedProject] = useState(null) const [output, setOutput] = useState("") const [message, setMessage] = useState(null) const [busyLabel, setBusyLabel] = useState(null) const [authSnapshot, setAuthSnapshot] = useState(null) const [githubStatus, setGithubStatus] = useState(null) - const [projectNavigationArmed, setProjectNavigationArmed] = useState(false) - const [projectAuthSnapshot, setProjectAuthSnapshot] = useState(null) const [createView, setCreateView] = useState(resetCreateView()) const [terminalSession, setTerminalSession] = useState(null) return { actionPrompt, - activeScreen, authSnapshot, busyLabel, createView, githubStatus, message, output, - ...portForwardState, - project, - projectNavigationArmed, - projectAuthSnapshot, setActionPrompt, - setActiveScreen, - selectedMenuIndex, - selectedProjectId, - setTerminalSession, setAuthSnapshot, setBusyLabel, setCreateView, setGithubStatus, setMessage, setOutput, + setTerminalSession, + terminalSession + } +} + +const useReadyProjectState = () => { + const [project, setSelectedProject] = useState(null) + const [projectNavigationArmed, setProjectNavigationArmed] = useState(false) + const [projectAuthSnapshot, setProjectAuthSnapshot] = useState(null) + const [projectBrowser, setProjectBrowser] = useState(null) + + return { + project, + projectNavigationArmed, + projectAuthSnapshot, + projectBrowser, setProjectNavigationArmed, setProjectAuthSnapshot, - setSelectedMenuIndex, - setSelectedProject, - setSelectedProjectId, - terminalSession + setProjectBrowser, + setSelectedProject + } +} + +export const useReadyState = (): ReadyState => { + const navigationState = useReadyNavigationState() + const panelState = useReadyPanelState() + const portForwardState = usePortForwardState() + const projectState = useReadyProjectState() + + return { + ...navigationState, + ...panelState, + ...portForwardState, + ...projectState } } @@ -244,6 +275,7 @@ const loadReadyPanel = (args: PanelAutoloadArgs): void => { maybeRefreshProjectAuthScreen(args) maybeLoadProjectPickerInfo(args) maybeLoadProjectPortForwards(args) + maybeLoadProjectBrowser(args) } export const usePanelAutoload = (args: PanelAutoloadArgs) => { diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 70f0bf3b..a4c09c77 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -7,6 +7,7 @@ import type { DashboardData, GithubAuthStatus, ProjectAuthSnapshot, + ProjectBrowserSession, ProjectDetails, ProjectPortForward } from "./api.js" @@ -40,9 +41,12 @@ export type ReadyLayoutProps = { readonly onRunAuthAction: (index: number) => void readonly onRunProjectAuthAction: (index: number) => void readonly onOpenMenuScreen: (index: number) => void + readonly onOpenProjectBrowserById: (projectId: string) => void + readonly onOpenProjectBrowser: () => void readonly onOpenProjectPortForward: () => void readonly onPortForwardInputChange: (value: string) => void readonly onRefreshProjectPortForwards: () => void + readonly onRefreshProjectBrowser: () => void readonly onSetActiveScreen: (screen: BrowserScreen) => void readonly onSelectMenu: (index: number) => void readonly onSelectProject: (projectId: string) => void @@ -55,6 +59,7 @@ export type ReadyLayoutProps = { readonly project: ProjectDetails | null readonly projectNavigationArmed: boolean readonly projectAuthSnapshot: ProjectAuthSnapshot | null + readonly projectBrowser: ProjectBrowserSession | null readonly selectedMenuIndex: number readonly selectedProjectId: string | null readonly selectedProjectSummary: DashboardData["projects"][number] | undefined diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index 94c642ce..2df1ba93 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -1,44 +1,37 @@ import type { JSX } from "react" +import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import type { ReadyLayoutProps } from "./app-ready-layout.js" import { MainMenuScreen } from "./app-ready-menu-screen.js" import { ScreenFrame, screenPadding } from "./app-ready-screen-frame.js" +import { TerminalScreen } from "./app-ready-terminal-screen.js" import { Box, Text } from "./elements.js" +import { BrowserPanel } from "./panel-browser.js" import { ContentPanel } from "./panel-content.js" import { PortForwardPanel } from "./panel-port-forwards.js" import { ProjectDetailsPanel } from "./panel-project-details.js" -import { TerminalPanel } from "./panel-terminal.js" import { OutputPanel, ProjectListPanel } from "./panels.js" -import { projectPickerScreen } from "./screen.js" -import type { BrowserScreen } from "./screen.js" type MainPanelsProps = Omit -const actionLabel = (menu: MainPanelsProps["currentMenu"]): string => { - if (menu === "Select") { - return "Open SSH" - } - if (menu === "ProjectAuth") { - return "Open project auth" - } - if (menu === "Ports") { - return "Open port" - } - if (menu === "Status") { - return "Load status" - } - if (menu === "Logs") { - return "Load logs" - } - if (menu === "Down") { - return "Stop project" - } - if (menu === "Delete") { - return "Delete project" - } - return "Run" +const actionLabels: Record = { + Auth: "Run", + Browser: "Open browser", + Create: "Run", + Delete: "Delete project", + Down: "Stop project", + DownAll: "Run", + Info: "Run", + Logs: "Load logs", + Ports: "Open port", + ProjectAuth: "Open project auth", + Quit: "Run", + Select: "Open SSH", + Status: "Load status" } +const actionLabel = (menu: MainPanelsProps["currentMenu"]): string => actionLabels[menu] + const screenTitle = (props: Pick): string => { if (props.activeScreen.tag === "Create") { return "docker-git / Create" @@ -81,8 +74,9 @@ const ProjectActionBar = ( { currentMenu, onRunCurrentMenuAction, + projectBrowser, selectedProjectSummary - }: Pick + }: Pick ): JSX.Element => ( {selectedProjectSummary === undefined ? "No project selected." : selectedProjectSummary.displayName} - - {actionLabel(currentMenu)} - + {currentMenu === "Browser" && !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) + ? {actionLabel(currentMenu)} + : ( + + {actionLabel(currentMenu)} + + )} ) @@ -116,6 +114,15 @@ const PortForwardDetails = (props: MainPanelsProps): JSX.Element => ( /> ) +const BrowserDetails = (props: MainPanelsProps): JSX.Element => ( + +) + const ProjectInfoDetails = (props: MainPanelsProps): JSX.Element => ( { if (props.currentMenu === "Ports") { return } + if (props.currentMenu === "Browser") { + return + } if (props.currentMenu === "ProjectAuth" || props.currentMenu === "Logs" || props.currentMenu === "Status") { return } @@ -197,6 +207,7 @@ const ProjectPickerScreen = (props: MainPanelsProps): JSX.Element => ( @@ -252,30 +263,6 @@ const OutputScreen = (props: MainPanelsProps): JSX.Element => ( ) -const TerminalScreen = ( - props: Pick -): JSX.Element | null => { - if (props.terminalSession === null) { - return null - } - const returnScreen: BrowserScreen = props.terminalSession.closePath.startsWith("/auth/") - ? { tag: "Auth" } - : projectPickerScreen() - return ( - - { - props.onTerminalClose() - props.onSetActiveScreen(returnScreen) - }} - onMessage={props.onTerminalMessage} - session={props.terminalSession} - /> - - ) -} - export const MainPanels = (props: MainPanelsProps): JSX.Element => { if (props.terminalSession !== null) { return diff --git a/packages/app/src/web/app-ready-screen-actions.ts b/packages/app/src/web/app-ready-screen-actions.ts new file mode 100644 index 00000000..5c0b9070 --- /dev/null +++ b/packages/app/src/web/app-ready-screen-actions.ts @@ -0,0 +1,67 @@ +import { runBrowserMenuAction } from "./actions.js" +import type { DashboardData } from "./api.js" +import type { createActionContext } from "./app-ready-actions.js" +import { resolveCurrentMenu } from "./app-ready-actions.js" +import { browserSidecarUnavailableMessage, canRunProjectBrowserAction } from "./app-ready-browser-openable.js" +import { cancelCreate } from "./app-ready-create.js" +import type { ReadyState } from "./app-ready-hooks.js" +import { isProjectMenu, menuScreen, outputScreen, projectPickerScreen, screenForMenu } from "./screen.js" + +const runCurrentMenuAction = ( + actionContext: ReturnType, + state: ReadyState +) => { + const menu = resolveCurrentMenu(state.selectedMenuIndex) + if (!canRunProjectBrowserAction(menu, state.projectBrowser, state.selectedProjectId)) { + state.setMessage(browserSidecarUnavailableMessage) + return + } + if (menu === "ProjectAuth") { + state.setActiveScreen({ tag: "ProjectAuth" }) + runBrowserMenuAction(menu, actionContext) + return + } + if (menu === "Logs" || menu === "Status") { + state.setActiveScreen(outputScreen()) + runBrowserMenuAction(menu, actionContext) + return + } + runBrowserMenuAction(menu, actionContext) +} + +export const bindScreenActions = ( + actionContext: ReturnType, + dashboard: DashboardData, + state: ReadyState +) => ({ + onBackScreen: () => { + if (state.activeScreen.tag === "Create") { + cancelCreate(actionContext, state.setCreateView) + return + } + if (state.activeScreen.tag === "ProjectAuth" || state.activeScreen.tag === "Output") { + state.setActiveScreen( + isProjectMenu(resolveCurrentMenu(state.selectedMenuIndex)) ? projectPickerScreen() : menuScreen() + ) + return + } + state.setProjectNavigationArmed(false) + state.setActiveScreen(menuScreen()) + }, + onOpenMenuScreen: (index: number) => { + const menu = resolveCurrentMenu(index) + state.setSelectedMenuIndex(index) + if (menu === "DownAll" || menu === "Quit") { + runBrowserMenuAction(menu, actionContext) + return + } + state.setActiveScreen(screenForMenu(menu)) + if (isProjectMenu(menu)) { + state.setProjectNavigationArmed(true) + state.setSelectedProjectId((projectId) => projectId ?? dashboard.projects[0]?.id ?? null) + } + }, + onRunCurrentMenuAction: () => { + runCurrentMenuAction(actionContext, state) + } +}) diff --git a/packages/app/src/web/app-ready-shortcut-runtime.ts b/packages/app/src/web/app-ready-shortcut-runtime.ts index d4c124e3..fde5a7fd 100644 --- a/packages/app/src/web/app-ready-shortcut-runtime.ts +++ b/packages/app/src/web/app-ready-shortcut-runtime.ts @@ -4,7 +4,8 @@ import type { CreateFlowView } from "../docker-git/menu-create-shared.js" import type { ActionPromptState } from "./action-prompt.js" import type { BrowserActionContext } from "./actions.js" import { runBrowserMenuAction } from "./actions.js" -import type { DashboardData } from "./api.js" +import type { DashboardData, ProjectBrowserSession } from "./api.js" +import { browserSidecarUnavailableMessage, canRunProjectBrowserAction } from "./app-ready-browser-openable.js" import { handleCreateKey } from "./app-ready-create.js" import { handleMenuNavigationKey, @@ -27,6 +28,7 @@ export type BrowserShortcutArgs = { readonly currentMenu: BrowserMenuTag readonly dashboard: DashboardData readonly projectsRoot: string + readonly projectBrowser: ProjectBrowserSession | null readonly selectedProjectId: string | null readonly setCreateView: Setter readonly setActiveScreen: Setter @@ -106,8 +108,13 @@ const handleMenuScreenKey = ( const runProjectPickerAction = ( currentMenu: BrowserMenuTag, context: BrowserActionContext, + projectBrowser: ProjectBrowserSession | null, setActiveScreen: Setter ): void => { + if (!canRunProjectBrowserAction(currentMenu, projectBrowser, context.selectedProjectId)) { + context.setMessage(browserSidecarUnavailableMessage) + return + } if (currentMenu === "ProjectAuth") { setActiveScreen({ tag: "ProjectAuth" }) runBrowserMenuAction(currentMenu, context) @@ -140,6 +147,7 @@ const handleProjectPickerShortcut = ( | "context" | "currentMenu" | "dashboard" + | "projectBrowser" | "selectedProjectId" | "setActiveScreen" | "setProjectNavigationArmed" @@ -166,7 +174,7 @@ const handleProjectPickerShortcut = ( } if (event.key === "Enter") { event.preventDefault() - runProjectPickerAction(args.currentMenu, args.context, args.setActiveScreen) + runProjectPickerAction(args.currentMenu, args.context, args.projectBrowser, args.setActiveScreen) return true } return handleRefreshShortcut(event, args) diff --git a/packages/app/src/web/app-ready-shortcuts.ts b/packages/app/src/web/app-ready-shortcuts.ts index b9614221..d421560c 100644 --- a/packages/app/src/web/app-ready-shortcuts.ts +++ b/packages/app/src/web/app-ready-shortcuts.ts @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react" import type { BrowserActionContext } from "./actions.js" import { + loadSelectedProjectBrowser, loadSelectedProjectInfo, loadSelectedProjectPorts, refreshAuthPanel, @@ -61,6 +62,7 @@ const isVerticalArrowKey = (event: ShortcutKeyboardEvent): boolean => event.key === "ArrowUp" || event.key === "ArrowDown" const projectPrimaryNavigationMenus: ReadonlySet = new Set([ + "Browser", "Delete", "Down", "Info", @@ -86,6 +88,7 @@ const resolveRefreshAction = ( ): (context: BrowserActionContext) => void => Match.value(currentMenu).pipe( Match.when("Auth", () => refreshAuthPanel), + Match.when("Browser", () => loadSelectedProjectBrowser), Match.when("Ports", () => loadSelectedProjectPorts), Match.when("ProjectAuth", () => refreshProjectAuthPanel), Match.orElse(() => loadSelectedProjectInfo) @@ -128,6 +131,7 @@ export const refreshCurrentMenu = ( export const shouldLoadProjectDetails = (currentMenu: BrowserMenuTag): boolean => Match.value(currentMenu).pipe( Match.when("Delete", () => true), + Match.when("Browser", () => true), Match.when("Down", () => true), Match.when("Info", () => true), Match.when("Logs", () => true), diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx new file mode 100644 index 00000000..87cc654c --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -0,0 +1,46 @@ +import type { JSX } from "react" + +import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" +import type { ReadyLayoutProps } from "./app-ready-layout.js" +import { Box } from "./elements.js" +import { TerminalPanel } from "./panel-terminal.js" +import { type BrowserScreen, projectPickerScreen } from "./screen.js" + +type TerminalScreenProps = Pick< + ReadyLayoutProps, + | "onOpenProjectBrowserById" + | "onSetActiveScreen" + | "onTerminalClose" + | "onTerminalMessage" + | "projectBrowser" + | "terminalSession" +> + +export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null => { + if (props.terminalSession === null) { + return null + } + const browserProjectId = props.terminalSession.browserProjectId + const canOpenBrowser = canOpenProjectBrowser(props.projectBrowser, browserProjectId) + const returnScreen: BrowserScreen = props.terminalSession.closePath.startsWith("/auth/") + ? { tag: "Auth" } + : projectPickerScreen() + return ( + + { + props.onTerminalClose() + props.onSetActiveScreen(returnScreen) + }} + onOpenBrowser={browserProjectId === undefined || !canOpenBrowser + ? undefined + : () => { + props.onOpenProjectBrowserById(browserProjectId) + }} + onMessage={props.onTerminalMessage} + session={props.terminalSession} + /> + + ) +} diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts new file mode 100644 index 00000000..93576cfe --- /dev/null +++ b/packages/app/src/web/app-ready-url.ts @@ -0,0 +1,271 @@ +import { useEffect, useRef } from "react" + +import type { DashboardData } from "./api.js" +import type { BrowserShortcutArgs } from "./app-ready-shortcut-runtime.js" +import { browserMenuIndex, browserMenuItems, type BrowserMenuTag } from "./menu.js" +import { type BrowserScreen, isProjectMenu, menuScreen, outputScreen, screenForMenu } from "./screen.js" +import type { ActiveTerminalSession } from "./terminal.js" + +type ReadyUrlNavigation = { + readonly activeScreen: BrowserScreen + readonly menu: BrowserMenuTag + readonly projectNavigationArmed: boolean + readonly selectedProjectId: string | null +} + +type ReadyUrlSyncArgs = { + readonly currentMenu: BrowserMenuTag + readonly dashboard: DashboardData + readonly state: Pick< + BrowserShortcutArgs, + | "activeScreen" + | "selectedProjectId" + | "setActiveScreen" + | "setProjectNavigationArmed" + | "setSelectedMenuIndex" + | "setSelectedProjectId" + | "terminalSession" + > +} + +type ReadyUrlPathArgs = { + readonly activeScreen: BrowserScreen + readonly currentMenu: BrowserMenuTag + readonly selectedProjectId: string | null + readonly selectedProjectSummary: DashboardData["projects"][number] | undefined + readonly terminalSession: ActiveTerminalSession | null +} + +const menuSlugs: Readonly> = { + Auth: "auth", + Browser: "browser", + Create: "create", + Delete: "delete", + Down: "down", + DownAll: "down-all", + Info: "info", + Logs: "logs", + Ports: "ports", + ProjectAuth: "project-auth", + Quit: "quit", + Select: "select", + Status: "status" +} + +const menuBySlug = new Map( + browserMenuItems.map(({ tag }) => [menuSlugs[tag], tag]) +) + +const reservedPathPrefixes: ReadonlyArray = ["/api/", "/assets/", "/b/", "/p/"] + +const isSshLinkUrl = (url: URL): boolean => + url.pathname.startsWith("/ssh/") || ((url.searchParams.get("ssh") ?? "").trim().length > 0) + +const isReservedPath = (pathname: string): boolean => + pathname === "/" || pathname === "/index.html" || reservedPathPrefixes.some((prefix) => pathname.startsWith(prefix)) + +const encodePathTail = (value: string): string => + value.split("/").map((segment) => encodeURIComponent(segment)).join("/") + +const decodePathTail = (segments: ReadonlyArray): string => + segments.map((segment) => decodeURIComponent(segment)).join("/").trim() + +const projectToken = (project: DashboardData["projects"][number] | undefined, fallback: string | null): string | null => + project?.projectKey ?? fallback + +const resolveProjectId = ( + projects: DashboardData["projects"], + token: string +): string | null => { + const normalizedToken = token.trim() + if (normalizedToken.length === 0) { + return null + } + const project = projects.find((candidate) => + candidate.id === normalizedToken || + candidate.projectKey === normalizedToken || + candidate.displayName === normalizedToken + ) + return project?.id ?? null +} + +const activeScreenFromMenu = (menu: BrowserMenuTag, outputRequested: boolean): BrowserScreen => { + if (outputRequested && (menu === "Logs" || menu === "Status")) { + return outputScreen() + } + if (menu === "ProjectAuth") { + return { tag: "ProjectAuth" } + } + return screenForMenu(menu) +} + +const parseMenuUrl = (rest: ReadonlyArray): ReadyUrlNavigation | null => { + const menu = menuBySlug.get(rest[0] ?? "") + return menu === undefined + ? null + : { + activeScreen: menuScreen(), + menu, + projectNavigationArmed: false, + selectedProjectId: null + } +} + +const parseMenuActionUrl = ( + rawSlug: string, + rest: ReadonlyArray, + projects: DashboardData["projects"] +): ReadyUrlNavigation | null => { + const menu = menuBySlug.get(rawSlug) + if (menu === undefined) { + return null + } + + const outputRequested = rest.at(-1) === "output" + const projectSegments = outputRequested ? rest.slice(0, -1) : rest + const selectedProjectId = isProjectMenu(menu) ? resolveProjectId(projects, decodePathTail(projectSegments)) : null + return { + activeScreen: activeScreenFromMenu(menu, outputRequested), + menu, + projectNavigationArmed: false, + selectedProjectId + } +} + +export const parseReadyUrlNavigation = ( + href: string, + projects: DashboardData["projects"] +): ReadyUrlNavigation | null => { + const url = new URL(href, "http://localhost") + if (isSshLinkUrl(url) || isReservedPath(url.pathname)) { + return null + } + + const segments = url.pathname.split("/").filter((segment) => segment.length > 0) + if (segments.length === 0) { + return null + } + + const rawSlug = segments[0] + if (rawSlug === undefined) { + return null + } + const rest = segments.slice(1) + return rawSlug === "menu" ? parseMenuUrl(rest) : parseMenuActionUrl(rawSlug, rest, projects) +} + +export const readyUrlPath = ( + { + activeScreen, + currentMenu, + selectedProjectId, + selectedProjectSummary, + terminalSession + }: ReadyUrlPathArgs +): string | null => { + if (terminalSession?.browserProjectId !== undefined) { + return `/ssh/${ + encodePathTail( + projectToken(selectedProjectSummary, terminalSession.browserProjectId) ?? terminalSession.browserProjectId + ) + }` + } + + const slug = menuSlugs[currentMenu] + if (activeScreen.tag === "Menu") { + return `/menu/${slug}` + } + + if (!isProjectMenu(currentMenu)) { + return `/${slug}` + } + + const token = projectToken(selectedProjectSummary, selectedProjectId) + const projectSuffix = token === null ? "" : `/${encodePathTail(token)}` + const outputSuffix = activeScreen.tag === "Output" ? "/output" : "" + return `/${slug}${projectSuffix}${outputSuffix}` +} + +const selectedProjectSummary = ({ dashboard, state }: ReadyUrlSyncArgs) => + dashboard.projects.find((project) => project.id === state.selectedProjectId) + +const applyReadyUrlNavigation = ( + args: ReadyUrlSyncArgs, + skipNextWriteRef: { current: boolean } +): void => { + const next = parseReadyUrlNavigation(globalThis.location.href, args.dashboard.projects) + if (next === null) { + return + } + skipNextWriteRef.current = true + args.state.setSelectedMenuIndex(browserMenuIndex(next.menu)) + args.state.setActiveScreen(next.activeScreen) + args.state.setProjectNavigationArmed(next.projectNavigationArmed) + args.state.setSelectedProjectId(next.selectedProjectId) +} + +const writeReadyUrl = ( + args: ReadyUrlSyncArgs, + skipInitialWriteRef: { current: boolean }, + skipNextWriteRef: { current: boolean } +) => { + if (skipInitialWriteRef.current) { + skipInitialWriteRef.current = false + return + } + if (skipNextWriteRef.current) { + skipNextWriteRef.current = false + return + } + + const currentUrl = new URL(globalThis.location.href) + if (isSshLinkUrl(currentUrl) && args.state.terminalSession === null) { + return + } + + const path = readyUrlPath({ + activeScreen: args.state.activeScreen, + currentMenu: args.currentMenu, + selectedProjectId: args.state.selectedProjectId, + selectedProjectSummary: selectedProjectSummary(args), + terminalSession: args.state.terminalSession + }) + if (path === null || `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` === path) { + return + } + globalThis.history.replaceState(globalThis.history.state, "", path) +} + +export const useReadyUrlSync = (args: ReadyUrlSyncArgs) => { + const argsRef = useRef(args) + const skipInitialWriteRef = useRef(true) + const skipNextWriteRef = useRef(false) + + argsRef.current = args + + useEffect(() => { + // URL reads are limited to initial load and browser back/forward. Normal clicks + // and arrow navigation write state to the URL instead of being overwritten by it. + const applyCurrentLocation = () => { + applyReadyUrlNavigation(argsRef.current, skipNextWriteRef) + } + + applyCurrentLocation() + const onPopState = applyCurrentLocation + globalThis.addEventListener("popstate", onPopState) + return () => { + globalThis.removeEventListener("popstate", onPopState) + } + }, []) + + useEffect(() => { + writeReadyUrl(args, skipInitialWriteRef, skipNextWriteRef) + }, [ + args.state.activeScreen, + args.currentMenu, + args.state.selectedProjectId, + selectedProjectSummary(args)?.projectKey, + args.state.terminalSession, + args.state.terminalSession?.browserProjectId + ]) +} diff --git a/packages/app/src/web/app-ready.tsx b/packages/app/src/web/app-ready.tsx index 72eace5d..fe80e698 100644 --- a/packages/app/src/web/app-ready.tsx +++ b/packages/app/src/web/app-ready.tsx @@ -22,10 +22,13 @@ type ReadyLayoutRenderArgs = { readonly onCreateCancel: () => void readonly onCreateSubmit: (forceWizard?: boolean) => void readonly onOpenMenuScreen: (index: number) => void + readonly onOpenProjectBrowserById: (projectId: string) => void + readonly onOpenProjectBrowser: () => void readonly onCloseProjectPortForward: (targetPort: number) => void readonly onOpenProjectPortForward: () => void readonly onPortForwardInputChange: (value: string) => void readonly onRefreshProjectPortForwards: () => void + readonly onRefreshProjectBrowser: () => void readonly onRunAuthAction: (index: number) => void readonly onRunCurrentMenuAction: () => void readonly onRunProjectAuthAction: (index: number) => void @@ -47,9 +50,12 @@ const readyActionProps = (actions: ReadyLayoutRenderArgs["actions"]) => ({ onCreateCancel: actions.onCreateCancel, onCreateSubmit: actions.onCreateSubmit, onOpenMenuScreen: actions.onOpenMenuScreen, + onOpenProjectBrowserById: actions.onOpenProjectBrowserById, + onOpenProjectBrowser: actions.onOpenProjectBrowser, onOpenProjectPortForward: actions.onOpenProjectPortForward, onPortForwardInputChange: actions.onPortForwardInputChange, onRefreshProjectPortForwards: actions.onRefreshProjectPortForwards, + onRefreshProjectBrowser: actions.onRefreshProjectBrowser, onRunAuthAction: actions.onRunAuthAction, onRunCurrentMenuAction: actions.onRunCurrentMenuAction, onRunProjectAuthAction: actions.onRunProjectAuthAction @@ -75,6 +81,7 @@ const readyStateProps = (state: ReadyLayoutRenderArgs["state"]) => ({ portForwards: state.portForwards, project: state.project, projectAuthSnapshot: state.projectAuthSnapshot, + projectBrowser: state.projectBrowser, projectNavigationArmed: state.projectNavigationArmed, selectedMenuIndex: state.selectedMenuIndex, selectedProjectId: state.selectedProjectId, diff --git a/packages/app/src/web/menu.ts b/packages/app/src/web/menu.ts index c4797ff7..f6c42a41 100644 --- a/packages/app/src/web/menu.ts +++ b/packages/app/src/web/menu.ts @@ -7,6 +7,7 @@ export type BrowserMenuTag = | "ProjectAuth" | "Info" | "Ports" + | "Browser" | "Status" | "Logs" | "Down" @@ -21,6 +22,7 @@ const browserMenuOrder: ReadonlyArray = [ "ProjectAuth", "Info", "Ports", + "Browser", "Status", "Logs", "Down", diff --git a/packages/app/src/web/panel-browser.tsx b/packages/app/src/web/panel-browser.tsx new file mode 100644 index 00000000..61be2f2c --- /dev/null +++ b/packages/app/src/web/panel-browser.tsx @@ -0,0 +1,132 @@ +import type { JSX } from "react" + +import { Box, Text } from "../ui/primitives.js" +import { projectBrowserCdpUrl, projectBrowserNoVncUrl, type ProjectBrowserSession, type ProjectSummary } from "./api.js" +import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" + +type BrowserPanelProps = { + readonly browser: ProjectBrowserSession | null + readonly onOpenBrowser: () => void + readonly onRefreshBrowser: () => void + readonly selectedProjectSummary: ProjectSummary | undefined +} + +const statusColor = (status: ProjectBrowserSession["status"]): string => { + if (status === "running") { + return "#56f39a" + } + if (status === "missing") { + return "#ff8aa0" + } + if (status === "stopped") { + return "#ffb86c" + } + return "#ffd166" +} + +const openUrl = (url: string): void => { + if (typeof globalThis.open === "function") { + globalThis.open(url, "_blank", "noopener") + } +} + +const BrowserLinks = ({ browser }: { readonly browser: ProjectBrowserSession }): JSX.Element => { + const noVncUrl = projectBrowserNoVncUrl(browser) + const cdpUrl = projectBrowserCdpUrl(browser) + return ( + + { + openUrl(noVncUrl) + }} + > + UI: {noVncUrl} + + { + openUrl(cdpUrl) + }} + > + CDP: {cdpUrl} + + + ) +} + +const BrowserStatusDetails = ( + { + browser, + canOpenBrowser + }: { + readonly browser: ProjectBrowserSession | null + readonly canOpenBrowser: boolean + } +): JSX.Element => { + if (browser === null) { + return Browser status is not loaded. + } + return ( + + + Container: {browser.containerName} + {browser.status} + + {canOpenBrowser + ? + : ( + + Enable Playwright MCP for this project and start it before opening the browser. + + )} + + ) +} + +const BrowserActions = ( + { + canOpenBrowser, + onOpenBrowser, + onRefreshBrowser + }: Pick & { readonly canOpenBrowser: boolean } +): JSX.Element => ( + + {canOpenBrowser + ? ( + + open browser + + ) + : open browser} + + refresh + + +) + +export const BrowserPanel = ( + { + browser, + onOpenBrowser, + onRefreshBrowser, + selectedProjectSummary + }: BrowserPanelProps +): JSX.Element => { + const canOpenBrowser = canOpenProjectBrowser(browser, selectedProjectSummary?.id ?? null) + return ( + + Browser + + Open the Playwright browser sidecar for the selected project. + + + Project: {selectedProjectSummary?.displayName ?? "not selected"} + + + + + ) +} diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index 26453ae3..29593121 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -36,7 +36,7 @@ type ContentPanelProps = { type StaticMenuTag = Exclude< BrowserMenuTag, - "Auth" | "Create" | "Delete" | "Down" | "Info" | "Ports" | "ProjectAuth" | "Select" + "Auth" | "Browser" | "Create" | "Delete" | "Down" | "Info" | "Ports" | "ProjectAuth" | "Select" > const StaticActionPanel = ( diff --git a/packages/app/src/web/panel-layout.tsx b/packages/app/src/web/panel-layout.tsx index 43e89afd..322eacb3 100644 --- a/packages/app/src/web/panel-layout.tsx +++ b/packages/app/src/web/panel-layout.tsx @@ -36,6 +36,7 @@ const renderListPurpose = (currentMenu: BrowserMenuTag): SelectPurpose => select const compactMenuLabels: Readonly> = { Auth: "Auth profiles", + Browser: "Browser", Create: "Create", Delete: "Delete", Down: "Down", diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index 46817ebc..72569474 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -12,6 +12,7 @@ import type { ActiveTerminalSession } from "./terminal.js" type TerminalPanelProps = { readonly onClose: () => void readonly onMessage: (message: string) => void + readonly onOpenBrowser?: (() => void) | undefined readonly session: ActiveTerminalSession } @@ -53,6 +54,15 @@ const closeButtonStyle: CSSProperties = { padding: "6px 10px" } +const headerActionsStyle: CSSProperties = { + alignItems: "center", + display: "flex", + flexShrink: 0, + flexWrap: "wrap", + gap: "8px", + justifyContent: "flex-end" +} + const statusColor = (status: TerminalStatus): string => { if (status === "attached") { return "#56f39a" @@ -69,9 +79,10 @@ const statusColor = (status: TerminalStatus): string => { const TerminalHeader = ( { onClose, + onOpenBrowser, session, status - }: Pick & { readonly status: TerminalStatus } + }: Pick & { readonly status: TerminalStatus } ): JSX.Element => (
@@ -85,18 +96,31 @@ const TerminalHeader = ( {session.subtitle}
- +
+ {session.browserProjectId === undefined || onOpenBrowser === undefined + ? null + : ( + + )} + +
) export const TerminalPanel = ( - { onClose, onMessage, session }: TerminalPanelProps + { onClose, onMessage, onOpenBrowser, session }: TerminalPanelProps ): JSX.Element => { const connectionRef = useRef({ opened: false }) const hostRef = useRef(null) @@ -113,7 +137,7 @@ export const TerminalPanel = ( return (
- +
) diff --git a/packages/app/src/web/screen.ts b/packages/app/src/web/screen.ts index 5c1109ee..089650f6 100644 --- a/packages/app/src/web/screen.ts +++ b/packages/app/src/web/screen.ts @@ -2,7 +2,7 @@ import type { BrowserMenuTag } from "./menu.js" export type BrowserProjectMenuTag = Extract< BrowserMenuTag, - "Delete" | "Down" | "Info" | "Logs" | "Ports" | "ProjectAuth" | "Select" | "Status" + "Browser" | "Delete" | "Down" | "Info" | "Logs" | "Ports" | "ProjectAuth" | "Select" | "Status" > export type BrowserScreen = @@ -19,15 +19,19 @@ export const outputScreen = (): BrowserScreen => ({ tag: "Output" }) export const projectPickerScreen = (): BrowserScreen => ({ tag: "ProjectPicker" }) -export const isProjectMenu = (menu: BrowserMenuTag): menu is BrowserProjectMenuTag => - menu === "Delete" || - menu === "Down" || - menu === "Info" || - menu === "Logs" || - menu === "Ports" || - menu === "ProjectAuth" || - menu === "Select" || - menu === "Status" +const projectMenuTags: ReadonlySet = new Set([ + "Browser", + "Delete", + "Down", + "Info", + "Logs", + "Ports", + "ProjectAuth", + "Select", + "Status" +]) + +export const isProjectMenu = (menu: BrowserMenuTag): menu is BrowserProjectMenuTag => projectMenuTags.has(menu) export const screenForMenu = (menu: BrowserMenuTag): BrowserScreen => { if (menu === "Create") { diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index 8a8ebdc2..d6080855 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -7,6 +7,8 @@ import { resolveApiBaseUrl, trimTrailingSlash } from "./api-http.js" import type { TerminalSession } from "./api-schema.js" export type ActiveTerminalSession = { + readonly browserProjectId?: string | undefined + readonly browserProjectName?: string | undefined readonly closePath: string readonly exitMessage: string readonly header: string diff --git a/packages/app/tests/docker-git/actions-browser.test.ts b/packages/app/tests/docker-git/actions-browser.test.ts new file mode 100644 index 00000000..0eea636d --- /dev/null +++ b/packages/app/tests/docker-git/actions-browser.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { afterEach, beforeEach, vi } from "vitest" + +import { openProjectBrowserById, openSelectedProjectBrowser } from "../../src/web/actions-browser.js" +import type { ProjectBrowserSession } from "../../src/web/api.js" +import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" + +const loadProjectBrowserMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadProjectBrowser: loadProjectBrowserMock, + projectBrowserCdpUrl: (browser: { readonly cdpPath: string }) => browser.cdpPath, + projectBrowserNoVncUrl: (browser: { readonly noVncPath: string }) => browser.noVncPath +})) + +const runningBrowser: ProjectBrowserSession = { + cdpPath: "/api/projects/project-1/browser/cdp", + cdpUrl: "ws://172.17.0.2:9222/devtools/browser/session", + containerName: "dg-browser-project-1", + noVncPath: "/api/projects/project-1/browser/novnc", + noVncUrl: "https://172.17.0.2:6080/vnc.html", + projectId: "project-1", + projectKey: "octocat/hello-world", + status: "running" +} + +const missingBrowser: ProjectBrowserSession = { + ...runningBrowser, + cdpPath: "", + cdpUrl: "", + noVncPath: "", + noVncUrl: "", + status: "missing" +} + +describe("web browser actions", () => { + beforeEach(() => { + loadProjectBrowserMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.effect("opens a running project browser by id", () => + Effect.gen(function*(_) { + const openMock = vi.fn>(() => null) + vi.stubGlobal("open", openMock) + loadProjectBrowserMock.mockImplementation((projectId: string) => Effect.succeed({ ...runningBrowser, projectId })) + + const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({ + selectedProjectName: "octocat/hello-world" + }) + + openProjectBrowserById("project-1", context) + + yield* _(waitForAssertion(() => { + expect(setProjectBrowser).toHaveBeenCalledWith(runningBrowser) + })) + + expect(openMock).toHaveBeenCalledWith("/api/projects/project-1/browser/novnc", "_blank", "noopener") + expect(setMessage).toHaveBeenLastCalledWith( + "Browser popup was blocked. Open /api/projects/project-1/browser/novnc manually. CDP endpoint: /api/projects/project-1/browser/cdp." + ) + })) + + it.effect("reports browser sidecar status instead of opening non-running browsers", () => + Effect.gen(function*(_) { + const openMock = vi.fn>(() => null) + vi.stubGlobal("open", openMock) + loadProjectBrowserMock.mockImplementation(() => Effect.succeed(missingBrowser)) + + const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({ + selectedProjectId: "project-1", + selectedProjectName: "octocat/hello-world" + }) + + openSelectedProjectBrowser(context) + + yield* _(waitForAssertion(() => { + expect(setProjectBrowser).toHaveBeenCalledWith(missingBrowser) + })) + + expect(openMock).not.toHaveBeenCalled() + expect(setMessage).toHaveBeenLastCalledWith( + "Browser sidecar is missing. Enable Playwright MCP and start the project first." + ) + })) + + it("does not call the browser endpoint when no project is selected", () => { + const { context, setMessage } = makeBrowserActionContext() + + openSelectedProjectBrowser(context) + + expect(loadProjectBrowserMock).not.toHaveBeenCalled() + expect(setMessage).toHaveBeenLastCalledWith("No project selected.") + }) +}) diff --git a/packages/app/tests/docker-git/actions-github-oauth.test.ts b/packages/app/tests/docker-git/actions-github-oauth.test.ts index e8ccbf5d..19b379f9 100644 --- a/packages/app/tests/docker-git/actions-github-oauth.test.ts +++ b/packages/app/tests/docker-git/actions-github-oauth.test.ts @@ -4,8 +4,8 @@ import { vi } from "vitest" import { githubLoginStreamMarkers } from "../../src/shared/auth-stream-markers.js" import { runGithubOauthMutation } from "../../src/web/actions-github-oauth.js" -import type { BrowserActionContext } from "../../src/web/actions-shared.js" import type { AuthSnapshot, GithubAuthStatus } from "../../src/web/api.js" +import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" const loginGithubStreamMock = vi.hoisted(() => vi.fn()) const loadAuthSnapshotMock = vi.hoisted(() => vi.fn()) @@ -41,42 +41,6 @@ const authSnapshot: AuthSnapshot = { totalEntries: 1 } -const makeContext = () => { - let output = "" - const setOutput: BrowserActionContext["setOutput"] = (next) => { - output = typeof next === "function" ? next(output) : next - } - const setMessage: BrowserActionContext["setMessage"] = vi.fn() - const reloadDashboard = vi.fn() - - return { - context: { - githubStatus: null, - portForwardInput: "", - reloadDashboard, - selectedProjectId: null, - selectedProjectName: null, - setActionPrompt: vi.fn(), - setActiveScreen: vi.fn(), - setAuthSnapshot: vi.fn(), - setBusyLabel: vi.fn(), - setGithubStatus: vi.fn(), - setMessage, - setOutput, - setPortForwardInput: vi.fn(), - setPortForwards: vi.fn(), - setProjectAuthSnapshot: vi.fn(), - setSelectedMenuIndex: vi.fn(), - setSelectedProject: vi.fn(), - setSelectedProjectId: vi.fn(), - setTerminalSession: vi.fn() - } satisfies BrowserActionContext, - output: () => output, - reloadDashboard, - setMessage - } -} - describe("web GitHub OAuth action", () => { it.effect("refreshes dashboard projects after successful OAuth", () => Effect.gen(function*(_) { @@ -95,19 +59,13 @@ describe("web GitHub OAuth action", () => { loadAuthSnapshotMock.mockImplementation(() => Effect.succeed(authSnapshot)) loadGithubStatusMock.mockImplementation(() => Effect.succeed(githubStatus)) - const { context, output, reloadDashboard, setMessage } = makeContext() + const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext() runGithubOauthMutation({ label: "" }, context) - yield* _( - Effect.tryPromise({ - catch: (error) => error, - try: () => - vi.waitFor(() => { - expect(reloadDashboard).toHaveBeenCalledTimes(1) - }) - }) - ) + yield* _(waitForAssertion(() => { + expect(reloadDashboard).toHaveBeenCalledTimes(1) + })) expect(output()).toBe("Copy your one-time code: ABCD-1234\nState dir ready: /home/dev/.docker-git\n") expect(context.setActionPrompt).toHaveBeenCalledWith(null) diff --git a/packages/app/tests/docker-git/app-ready-browser-openable.test.ts b/packages/app/tests/docker-git/app-ready-browser-openable.test.ts new file mode 100644 index 00000000..c9b88182 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-browser-openable.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import type { ProjectBrowserSession } from "../../src/web/api.js" +import { + browserSidecarUnavailableMessage, + canOpenProjectBrowser, + canRunProjectBrowserAction +} from "../../src/web/app-ready-browser-openable.js" + +const browser: ProjectBrowserSession = { + cdpPath: "/api/projects/project-1/browser/cdp", + cdpUrl: "ws://browser", + containerName: "project-1-browser", + noVncPath: "/api/projects/project-1/browser/novnc", + noVncUrl: "https://browser/vnc.html", + projectId: "project-1", + projectKey: "org/repo", + status: "running" +} + +describe("browser open availability", () => { + it("enables browser actions only for the running sidecar on the same project", () => { + expect(canOpenProjectBrowser(browser, "project-1")).toBe(true) + expect(canOpenProjectBrowser({ ...browser, status: "missing" }, "project-1")).toBe(false) + expect(canOpenProjectBrowser(browser, "project-2")).toBe(false) + expect(canOpenProjectBrowser(null, "project-1")).toBe(false) + }) + + it("gates only the browser menu action by sidecar availability", () => { + expect(canRunProjectBrowserAction("Browser", browser, "project-1")).toBe(true) + expect(canRunProjectBrowserAction("Browser", null, "project-1")).toBe(false) + expect(canRunProjectBrowserAction("Info", null, "project-1")).toBe(true) + expect(browserSidecarUnavailableMessage).toContain("Enable Playwright MCP") + }) +}) diff --git a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts index a9396a80..1b5cadb5 100644 --- a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts +++ b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts @@ -82,6 +82,7 @@ describe("app-ready-shortcuts", () => { expect(usesProjectPrimaryNavigation("Select")).toBe(true) expect(usesProjectPrimaryNavigation("Info")).toBe(true) expect(usesProjectPrimaryNavigation("Ports")).toBe(true) + expect(usesProjectPrimaryNavigation("Browser")).toBe(true) expect(usesProjectPrimaryNavigation("ProjectAuth")).toBe(true) expect(usesProjectPrimaryNavigation("Logs")).toBe(true) expect(usesProjectPrimaryNavigation("Create")).toBe(false) @@ -148,6 +149,7 @@ describe("app-ready-shortcuts", () => { expect(shouldRefreshProjectDetails("ProjectAuth", false, "project-a")).toBe(true) expect(shouldRefreshProjectDetails("Select", false, "project-a")).toBe(true) expect(shouldRefreshProjectDetails("Ports", false, "project-a")).toBe(true) + expect(shouldRefreshProjectDetails("Browser", false, "project-a")).toBe(true) expect(shouldRefreshProjectDetails("Select", true, "project-a")).toBe(true) expect(shouldRefreshProjectDetails("Status", false, "project-a")).toBe(true) expect(shouldRefreshProjectDetails("Logs", false, "project-a")).toBe(true) diff --git a/packages/app/tests/docker-git/app-ready-url.test.ts b/packages/app/tests/docker-git/app-ready-url.test.ts new file mode 100644 index 00000000..7c352718 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-url.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" + +import type { DashboardData } from "../../src/web/api.js" +import { parseReadyUrlNavigation, readyUrlPath } from "../../src/web/app-ready-url.js" + +const dashboard: DashboardData = { + apiBaseUrl: "/api", + health: { + cwd: "/repo", + ok: true, + projectsRoot: "/home/dev/.docker-git", + revision: null + }, + projects: [ + { + clonedOnHostname: "host", + displayName: "octocat/hello-world", + id: "project-1", + projectKey: "octocat/hello-world", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git", + sshSessions: 0, + startedAtEpochMs: null, + startedAtIso: null, + status: "running", + statusLabel: "Up" + } + ] +} + +const selectedProjectSummary = dashboard.projects[0] + +describe("app ready URL state", () => { + it("renders menu tab highlights as copyable URLs", () => { + expect(readyUrlPath({ + activeScreen: { tag: "Menu" }, + currentMenu: "Browser", + selectedProjectId: null, + selectedProjectSummary: undefined, + terminalSession: null + })).toBe("/menu/browser") + }) + + it("renders selected project tabs as readable deep links", () => { + expect(readyUrlPath({ + activeScreen: { tag: "ProjectPicker" }, + currentMenu: "Browser", + selectedProjectId: "project-1", + selectedProjectSummary, + terminalSession: null + })).toBe("/browser/octocat/hello-world") + }) + + it("renders active SSH project terminals as SSH deep links", () => { + expect(readyUrlPath({ + activeScreen: { tag: "ProjectPicker" }, + currentMenu: "Select", + selectedProjectId: "project-1", + selectedProjectSummary, + terminalSession: { + browserProjectId: "project-1", + closePath: "/projects/project-1/terminal-sessions/session-1", + exitMessage: "done", + header: "SSH terminal: octocat/hello-world", + pendingDeleteMessage: "closed", + readyMessage: "ready", + session: { + createdAt: "2026-04-15T00:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh dev@127.0.0.1", + status: "attached" + }, + subtitle: "ssh dev@127.0.0.1", + websocketPath: "/projects/project-1/terminal-sessions/session-1/ws" + } + })).toBe("/ssh/octocat/hello-world") + }) + + it("parses project tab URLs back into app navigation state", () => { + expect(parseReadyUrlNavigation("https://docker-git.local/browser/octocat/hello-world", dashboard.projects)).toEqual( + { + activeScreen: { tag: "ProjectPicker" }, + menu: "Browser", + projectNavigationArmed: false, + selectedProjectId: "project-1" + } + ) + }) + + it("keeps /ssh links owned by SSH auto-connect flow", () => { + expect(parseReadyUrlNavigation("https://docker-git.local/ssh/octocat/hello-world", dashboard.projects)).toBeNull() + expect(parseReadyUrlNavigation("https://docker-git.local/?ssh=octocat/hello-world", dashboard.projects)).toBeNull() + }) +}) diff --git a/packages/app/tests/docker-git/browser-action-context-fixture.ts b/packages/app/tests/docker-git/browser-action-context-fixture.ts new file mode 100644 index 00000000..a5c91a68 --- /dev/null +++ b/packages/app/tests/docker-git/browser-action-context-fixture.ts @@ -0,0 +1,52 @@ +import { Effect } from "effect" +import { vi } from "vitest" + +import type { BrowserActionContext } from "../../src/web/actions-shared.js" + +export const waitForAssertion = (assertion: () => void) => + Effect.tryPromise({ + catch: (error) => error, + try: () => vi.waitFor(assertion) + }) + +export const makeBrowserActionContext = ( + overrides: Partial = {} +) => { + let output = "" + const setOutput: BrowserActionContext["setOutput"] = (next) => { + output = typeof next === "function" ? next(output) : next + } + const reloadDashboard = vi.fn() + const setMessage = vi.fn() + const setProjectBrowser = vi.fn() + + return { + context: { + githubStatus: null, + portForwardInput: "", + reloadDashboard, + selectedProjectId: null, + selectedProjectName: null, + setActionPrompt: vi.fn(), + setActiveScreen: vi.fn(), + setAuthSnapshot: vi.fn(), + setBusyLabel: vi.fn(), + setGithubStatus: vi.fn(), + setMessage, + setOutput, + setPortForwardInput: vi.fn(), + setPortForwards: vi.fn(), + setProjectAuthSnapshot: vi.fn(), + setProjectBrowser, + setSelectedMenuIndex: vi.fn(), + setSelectedProject: vi.fn(), + setSelectedProjectId: vi.fn(), + setTerminalSession: vi.fn(), + ...overrides + } satisfies BrowserActionContext, + output: () => output, + reloadDashboard, + setMessage, + setProjectBrowser + } +} diff --git a/packages/app/vite.web.config.ts b/packages/app/vite.web.config.ts index 3a5b711c..1695398f 100644 --- a/packages/app/vite.web.config.ts +++ b/packages/app/vite.web.config.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url" import { gridlandWebPlugin } from "@gridland/web/vite-plugin" import react from "@vitejs/plugin-react" import { defineConfig, loadEnv, type PluginOption } from "vite" -import { WebSocket, WebSocketServer } from "ws" +import { type RawData, WebSocket, WebSocketServer } from "ws" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -17,9 +17,16 @@ const noStoreHeaders = { } const createProxy = (apiTarget: string) => ({ + "/b": { + target: apiTarget, + changeOrigin: false, + xfwd: true, + ws: false + }, "/api": { target: apiTarget, - changeOrigin: true, + changeOrigin: false, + xfwd: true, ws: false, rewrite: (requestPath: string) => requestPath.replace(/^\/api/u, "") } @@ -31,12 +38,63 @@ const resolveUpstreamPath = (requestUrl: string | undefined): string => { return `${pathname}${parsed.search}` } -const isTerminalWebSocketRequest = (request: IncomingMessage): boolean => { +const isApiTerminalWebSocketRequest = (request: IncomingMessage): boolean => { const parsed = new URL(request.url ?? "/", "http://localhost") return parsed.pathname.startsWith("/api/") && parsed.pathname.endsWith("/ws") } -const proxyTerminalWebSocket = ( +const isBrowserWebSocketRequest = (request: IncomingMessage): boolean => { + const parsed = new URL(request.url ?? "/", "http://localhost") + return parsed.pathname.startsWith("/b/") +} + +const firstHeader = (value: string | ReadonlyArray | undefined): string | undefined => + typeof value === "string" ? value : value?.[0] + +const proxyForwardHeaders = (request: IncomingMessage): Record => { + const forwardedHost = firstHeader(request.headers["x-forwarded-host"]) ?? firstHeader(request.headers.host) + const forwardedProto = firstHeader(request.headers["x-forwarded-proto"]) ?? "http" + return { + ...(forwardedHost === undefined ? {} : { "x-forwarded-host": forwardedHost }), + "x-forwarded-proto": forwardedProto + } +} + +const bridgeWebSockets = (clientSocket: WebSocket, upstream: WebSocket): void => { + const pending: Array<{ readonly data: RawData; readonly isBinary: boolean }> = [] + const sendWhenOpen = (socket: WebSocket, data: RawData, isBinary: boolean): void => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(data, { binary: isBinary }) + } + } + const flushPending = (): void => { + for (const message of pending.splice(0)) { + sendWhenOpen(upstream, message.data, message.isBinary) + } + } + clientSocket.on("message", (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + sendWhenOpen(upstream, data, isBinary) + return + } + pending.push({ data, isBinary }) + }) + clientSocket.on("close", () => { + upstream.close() + }) + upstream.on("open", flushPending) + upstream.on("message", (data, isBinary) => { + sendWhenOpen(clientSocket, data, isBinary) + }) + upstream.on("close", () => { + clientSocket.close() + }) + upstream.on("error", () => { + clientSocket.close() + }) +} + +const proxyWebSocket = ( apiTarget: string, request: IncomingMessage, socket: Duplex, @@ -45,30 +103,12 @@ const proxyTerminalWebSocket = ( ): void => { const apiUrl = new URL(apiTarget) apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:" - const upstream = new WebSocket(`${apiUrl.origin}${resolveUpstreamPath(request.url)}`) - - upstream.on("open", () => { - webSocketServer.handleUpgrade(request, socket, head, (clientSocket) => { - clientSocket.on("message", (data, isBinary) => { - upstream.send(data, { binary: isBinary }) - }) - clientSocket.on("close", () => { - upstream.close() - }) - upstream.on("message", (data, isBinary) => { - clientSocket.send(data, { binary: isBinary }) - }) - upstream.on("close", () => { - clientSocket.close() - }) - upstream.on("error", () => { - clientSocket.close() - }) - }) - }) - - upstream.on("error", () => { - socket.destroy() + webSocketServer.handleUpgrade(request, socket, head, (clientSocket) => { + const upstream = new WebSocket( + `${apiUrl.origin}${resolveUpstreamPath(request.url)}`, + { headers: proxyForwardHeaders(request) } + ) + bridgeWebSockets(clientSocket, upstream) }) } @@ -77,10 +117,10 @@ const terminalWebSocketProxyPlugin = (apiTarget: string): PluginOption => ({ configureServer(server) { const webSocketServer = new WebSocketServer({ noServer: true }) server.httpServer?.prependListener("upgrade", (request, socket, head) => { - if (!isTerminalWebSocketRequest(request)) { + if (!isApiTerminalWebSocketRequest(request) && !isBrowserWebSocketRequest(request)) { return } - proxyTerminalWebSocket(apiTarget, request, socket, head, webSocketServer) + proxyWebSocket(apiTarget, request, socket, head, webSocketServer) }) } })