|
@@ -0,0 +1,205 @@
|
|
|
|
|
+import { EffectBridge } from "@/effect"
|
|
|
|
|
+import { Pty } from "@/pty"
|
|
|
|
|
+import { PtyID } from "@/pty/schema"
|
|
|
|
|
+import { Effect, Layer, Schema } from "effect"
|
|
|
|
|
+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
|
|
|
|
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
|
|
|
|
+import * as Socket from "effect/unstable/socket/Socket"
|
|
|
|
|
+import { Authorization } from "./auth"
|
|
|
|
|
+
|
|
|
|
|
+const root = "/pty"
|
|
|
|
|
+const Params = Schema.Struct({
|
|
|
|
|
+ ptyID: PtyID,
|
|
|
|
|
+})
|
|
|
|
|
+const CursorQuery = Schema.Struct({
|
|
|
|
|
+ cursor: Schema.optional(Schema.String),
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+export const PtyPaths = {
|
|
|
|
|
+ list: root,
|
|
|
|
|
+ create: root,
|
|
|
|
|
+ get: `${root}/:ptyID`,
|
|
|
|
|
+ update: `${root}/:ptyID`,
|
|
|
|
|
+ remove: `${root}/:ptyID`,
|
|
|
|
|
+ connect: `${root}/:ptyID/connect`,
|
|
|
|
|
+} as const
|
|
|
|
|
+
|
|
|
|
|
+export const PtyApi = HttpApi.make("pty")
|
|
|
|
|
+ .add(
|
|
|
|
|
+ HttpApiGroup.make("pty")
|
|
|
|
|
+ .add(
|
|
|
|
|
+ HttpApiEndpoint.get("list", PtyPaths.list, {
|
|
|
|
|
+ success: Schema.Array(Pty.Info),
|
|
|
|
|
+ }).annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ identifier: "pty.list",
|
|
|
|
|
+ summary: "List PTY sessions",
|
|
|
|
|
+ description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ HttpApiEndpoint.post("create", PtyPaths.create, {
|
|
|
|
|
+ payload: Pty.CreateInput,
|
|
|
|
|
+ success: Pty.Info,
|
|
|
|
|
+ }).annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ identifier: "pty.create",
|
|
|
|
|
+ summary: "Create PTY session",
|
|
|
|
|
+ description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ HttpApiEndpoint.get("get", PtyPaths.get, {
|
|
|
|
|
+ params: { ptyID: PtyID },
|
|
|
|
|
+ success: Pty.Info,
|
|
|
|
|
+ error: HttpApiError.NotFound,
|
|
|
|
|
+ }).annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ identifier: "pty.get",
|
|
|
|
|
+ summary: "Get PTY session",
|
|
|
|
|
+ description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ HttpApiEndpoint.put("update", PtyPaths.update, {
|
|
|
|
|
+ params: { ptyID: PtyID },
|
|
|
|
|
+ payload: Pty.UpdateInput,
|
|
|
|
|
+ success: Pty.Info,
|
|
|
|
|
+ error: HttpApiError.NotFound,
|
|
|
|
|
+ }).annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ identifier: "pty.update",
|
|
|
|
|
+ summary: "Update PTY session",
|
|
|
|
|
+ description: "Update properties of an existing pseudo-terminal (PTY) session.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
|
|
|
|
+ params: { ptyID: PtyID },
|
|
|
|
|
+ success: Schema.Boolean,
|
|
|
|
|
+ }).annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ identifier: "pty.remove",
|
|
|
|
|
+ summary: "Remove PTY session",
|
|
|
|
|
+ description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ .annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ title: "pty",
|
|
|
|
|
+ description: "Experimental HttpApi PTY routes.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ .middleware(Authorization),
|
|
|
|
|
+ )
|
|
|
|
|
+ .annotateMerge(
|
|
|
|
|
+ OpenApi.annotations({
|
|
|
|
|
+ title: "opencode experimental HttpApi",
|
|
|
|
|
+ version: "0.0.1",
|
|
|
|
|
+ description: "Experimental HttpApi surface for selected instance routes.",
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+export const ptyHandlers = Layer.unwrap(
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const pty = yield* Pty.Service
|
|
|
|
|
+
|
|
|
|
|
+ const list = Effect.fn("PtyHttpApi.list")(function* () {
|
|
|
|
|
+ return yield* pty.list()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
|
|
|
|
+ const bridge = yield* EffectBridge.make()
|
|
|
|
|
+ return yield* Effect.promise(() =>
|
|
|
|
|
+ bridge.promise(
|
|
|
|
|
+ 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 } }) {
|
|
|
|
|
+ const info = yield* pty.get(ctx.params.ptyID)
|
|
|
|
|
+ if (!info) return yield* new HttpApiError.NotFound({})
|
|
|
|
|
+ return info
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
|
|
|
|
|
+ params: { ptyID: PtyID }
|
|
|
|
|
+ payload: typeof Pty.UpdateInput.Type
|
|
|
|
|
+ }) {
|
|
|
|
|
+ const info = yield* pty.update(ctx.params.ptyID, {
|
|
|
|
|
+ ...ctx.payload,
|
|
|
|
|
+ size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!info) return yield* new HttpApiError.NotFound({})
|
|
|
|
|
+ return info
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
|
|
|
|
|
+ yield* pty.remove(ctx.params.ptyID)
|
|
|
|
|
+ return true
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
|
|
|
|
|
+ handlers
|
|
|
|
|
+ .handle("list", list)
|
|
|
|
|
+ .handle("create", create)
|
|
|
|
|
+ .handle("get", get)
|
|
|
|
|
+ .handle("update", update)
|
|
|
|
|
+ .handle("remove", remove),
|
|
|
|
|
+ )
|
|
|
|
|
+ }),
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+export const ptyConnectRoute = HttpRouter.add(
|
|
|
|
|
+ "GET",
|
|
|
|
|
+ PtyPaths.connect,
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const pty = yield* Pty.Service
|
|
|
|
|
+ const params = yield* HttpRouter.schemaPathParams(Params)
|
|
|
|
|
+ if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
|
|
|
|
+
|
|
|
|
|
+ const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
|
|
|
|
+ const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
|
|
|
|
+ const cursor =
|
|
|
|
|
+ parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
|
|
|
|
|
+ const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
|
|
|
|
+ const write = yield* socket.writer
|
|
|
|
|
+ let closed = false
|
|
|
|
|
+ const adapter = {
|
|
|
|
|
+ get readyState() {
|
|
|
|
|
+ return closed ? 3 : 1
|
|
|
|
|
+ },
|
|
|
|
|
+ send: (data: string | Uint8Array | ArrayBuffer) => {
|
|
|
|
|
+ if (closed) return
|
|
|
|
|
+ Effect.runFork(
|
|
|
|
|
+ write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ close: (code?: number, reason?: string) => {
|
|
|
|
|
+ if (closed) return
|
|
|
|
|
+ closed = true
|
|
|
|
|
+ Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
|
|
|
|
+ if (!handler) return HttpServerResponse.empty()
|
|
|
|
|
+
|
|
|
|
|
+ yield* socket
|
|
|
|
|
+ .runRaw((message) => {
|
|
|
|
|
+ handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
|
|
|
|
|
+ })
|
|
|
|
|
+ .pipe(
|
|
|
|
|
+ Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
|
|
|
|
+ Effect.ensuring(
|
|
|
|
|
+ Effect.sync(() => {
|
|
|
|
|
+ closed = true
|
|
|
|
|
+ handler.onClose()
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ Effect.orDie,
|
|
|
|
|
+ )
|
|
|
|
|
+ return HttpServerResponse.empty()
|
|
|
|
|
+ }).pipe(Effect.provide(Pty.defaultLayer)),
|
|
|
|
|
+)
|