Files
zmo-cli/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
T
Kit LangtonandGitHub 76d814e747 refactor(server): unify instance httpapi middleware routing
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
2026-05-27 08:45:11 -04:00

218 lines
8.2 KiB
TypeScript

import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, PtyConnectApi } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
}
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
return yield* pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
})
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
return yield* pty.get(ctx.params.ptyID).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
return yield* pty
.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
.pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
return true
})
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" })
yield* pty.get(ctx.params.ptyID).pipe(
Effect.catchTag("Pty.NotFoundError", (error) =>
Effect.fail(
new ApiError.PtyNotFoundError({
ptyID: error.ptyID,
message: `PTY session not found: ${error.ptyID}`,
}),
),
),
)
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
.handle("connectToken", connectToken)
}),
)
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
return handlers.handleRaw(
"connect",
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
if (!registered) {
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
return HttpServerResponse.empty()
}
const bridge = yield* EffectBridge.make()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
const handler = yield* pty
.connect(ctx.params.ptyID, adapter, cursor)
.pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
),
)
if (!handler) return HttpServerResponse.empty()
// The handshake runs inside `socket.runRaw`, after the input callback is
// registered, so the client cannot send frames before PTY input is wired.
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
)