refactor(core): canonicalize pty service (#32182)
This commit is contained in:
parent
7efade2d53
commit
f2cf607376
30 changed files with 1132 additions and 504 deletions
221
packages/server/src/handlers/pty.ts
Normal file
221
packages/server/src/handlers/pty.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect, Queue } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Api } from "../api"
|
||||
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
|
||||
import { ForbiddenError, PtyNotFoundError } from "../errors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "../groups/pty"
|
||||
import { response } from "../groups/location"
|
||||
|
||||
const ticketScope = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
return { directory: location.directory as string, workspaceID: location.workspaceID }
|
||||
})
|
||||
|
||||
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"pty.list",
|
||||
Effect.fn(function* () {
|
||||
return yield* response((yield* Pty.Service).list())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"pty.create",
|
||||
Effect.fn(function* (ctx) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* response(
|
||||
pty.create({
|
||||
...ctx.payload,
|
||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"pty.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* response(
|
||||
pty
|
||||
.get(ctx.params.ptyID)
|
||||
.pipe(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
() =>
|
||||
new PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"pty.update",
|
||||
Effect.fn(function* (ctx) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* response(
|
||||
pty
|
||||
.update(ctx.params.ptyID, {
|
||||
...ctx.payload,
|
||||
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
() =>
|
||||
new PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"pty.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty
|
||||
.remove(ctx.params.ptyID)
|
||||
.pipe(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
() =>
|
||||
new PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"pty.connectToken",
|
||||
Effect.fn(function* (ctx) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
// The custom header forces a CORS preflight, so cross-origin browser pages cannot
|
||||
// mint tickets without passing the server's origin policy.
|
||||
if (
|
||||
request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
|
||||
!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
|
||||
)
|
||||
return yield* new ForbiddenError({ message: "Invalid PTY connect token request" })
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty
|
||||
.get(ctx.params.ptyID)
|
||||
.pipe(
|
||||
Effect.catchTag(
|
||||
"Pty.NotFoundError",
|
||||
() =>
|
||||
new PtyNotFoundError({
|
||||
ptyID: ctx.params.ptyID,
|
||||
message: `PTY session not found: ${ctx.params.ptyID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) }))
|
||||
}),
|
||||
)
|
||||
.handleRaw(
|
||||
"pty.connect",
|
||||
Effect.fn("PtyHandler.connect")(function* (ctx) {
|
||||
const pty = yield* Pty.Service
|
||||
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 url = new URL(ctx.request.url, "http://localhost")
|
||||
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = url.searchParams.get("cursor")
|
||||
const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor)
|
||||
const cursor =
|
||||
cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1
|
||||
? cursorNumber
|
||||
: 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),
|
||||
)
|
||||
|
||||
// Outbound frames flow through one queue drained by a single writer so replay, live
|
||||
// output, and the close frame keep their order.
|
||||
// TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route.
|
||||
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
const attachment = yield* pty
|
||||
.attach(ctx.params.ptyID, {
|
||||
cursor,
|
||||
onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
|
||||
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"Pty.NotFoundError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
|
||||
"Pty.ExitedError": () =>
|
||||
closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)),
|
||||
}),
|
||||
)
|
||||
if (!attachment) return HttpServerResponse.empty()
|
||||
|
||||
for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
|
||||
Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
|
||||
attachment.activate()
|
||||
|
||||
const drain = Effect.gen(function* () {
|
||||
while (true) {
|
||||
const item = yield* Queue.take(outbox)
|
||||
yield* write(item)
|
||||
if (item instanceof Socket.CloseEvent) return
|
||||
}
|
||||
})
|
||||
|
||||
yield* Effect.race(
|
||||
drain,
|
||||
socket.runRaw((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue