|
|
@@ -1,8 +1,15 @@
|
|
|
-import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
|
|
+import { Cause, Effect, Queue, Stream } from "effect"
|
|
|
import { Headers } from "effect/unstable/http"
|
|
|
+import { Socket } from "effect/unstable/socket"
|
|
|
import { AIError, TransportReason } from "../../schema"
|
|
|
import * as HttpTransport from "./http"
|
|
|
import type { Transport } from "./index"
|
|
|
+import type {
|
|
|
+ ChannelObservation,
|
|
|
+ WebSocketChannelDriver,
|
|
|
+ WebSocketChannelExchange,
|
|
|
+ WebSocketChannelExecutor,
|
|
|
+} from "./websocket-channel"
|
|
|
|
|
|
export interface WebSocketRequest {
|
|
|
readonly url: string
|
|
|
@@ -15,17 +22,15 @@ export interface WebSocketConnection {
|
|
|
readonly close: Effect.Effect<void, never>
|
|
|
}
|
|
|
|
|
|
-export interface Interface {
|
|
|
+export interface WebSocketConnector {
|
|
|
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
|
|
}
|
|
|
|
|
|
-type WebSocketConstructorWithHeaders = new (
|
|
|
+type WebSocketConstructorWithHeaders = (
|
|
|
url: string,
|
|
|
options?: { readonly headers?: Headers.Headers },
|
|
|
) => globalThis.WebSocket
|
|
|
|
|
|
-export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
|
|
-
|
|
|
const transportError = (
|
|
|
method: string,
|
|
|
message: string,
|
|
|
@@ -37,7 +42,7 @@ const transportError = (
|
|
|
} = {},
|
|
|
) =>
|
|
|
new AIError({
|
|
|
- module: "WebSocketExecutor",
|
|
|
+ module: "WebSocketConnector",
|
|
|
method,
|
|
|
reason: new TransportReason({
|
|
|
message,
|
|
|
@@ -165,19 +170,25 @@ const webSocketUrl = (value: string) =>
|
|
|
})
|
|
|
|
|
|
export const open = (input: WebSocketRequest) =>
|
|
|
- Effect.try({
|
|
|
- try: () =>
|
|
|
- new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
|
|
- catch: (error) =>
|
|
|
- transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
|
|
- url: input.url,
|
|
|
- kind: "open",
|
|
|
- phase: "connect",
|
|
|
- delivery: "not-sent",
|
|
|
- }),
|
|
|
- }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
|
|
-
|
|
|
-export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const constructor = yield* Socket.WebSocketConstructor
|
|
|
+ const ws = yield* Effect.try({
|
|
|
+ try: () =>
|
|
|
+ // Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
|
|
+ // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
|
+ (constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
|
|
+ headers: input.headers,
|
|
|
+ }),
|
|
|
+ catch: (error) =>
|
|
|
+ transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
|
|
+ url: input.url,
|
|
|
+ kind: "open",
|
|
|
+ phase: "connect",
|
|
|
+ delivery: "not-sent",
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ return yield* fromWebSocket(ws, input)
|
|
|
+ })
|
|
|
|
|
|
export const fromWebSocket = (
|
|
|
ws: globalThis.WebSocket,
|
|
|
@@ -263,6 +274,57 @@ export const fromWebSocket = (
|
|
|
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
|
|
typeof message === "string" ? message : decoder.decode(message)
|
|
|
|
|
|
+const observationFrame = (observation: ChannelObservation) => {
|
|
|
+ if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
|
|
+ return Effect.succeed(observation.frame)
|
|
|
+ return Effect.fail(observation.error)
|
|
|
+}
|
|
|
+
|
|
|
+const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
|
|
|
+
|
|
|
+export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
|
|
|
+ execute: (exchange) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const connection = yield* Effect.acquireRelease(
|
|
|
+ connector
|
|
|
+ .open(exchange.connect)
|
|
|
+ .pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
|
|
|
+ (connection) => connection.close,
|
|
|
+ )
|
|
|
+ const create = yield* exchange.driver.create(undefined)
|
|
|
+ yield* connection.sendText(create.message)
|
|
|
+ const decoder = new TextDecoder()
|
|
|
+ let observed = false
|
|
|
+ return {
|
|
|
+ frames: connection.messages.pipe(
|
|
|
+ Stream.map((message) => {
|
|
|
+ observed = true
|
|
|
+ return messageText(message, decoder)
|
|
|
+ }),
|
|
|
+ Stream.mapError((error) =>
|
|
|
+ annotateTransportError(error, {
|
|
|
+ phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
|
|
+ delivery: observed ? "accepted" : "ambiguous",
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
|
|
+ Stream.takeUntil(observationTerminal),
|
|
|
+ Stream.mapEffect(observationFrame),
|
|
|
+ ),
|
|
|
+ complete: Effect.void,
|
|
|
+ }
|
|
|
+ }),
|
|
|
+})
|
|
|
+
|
|
|
+export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
|
|
|
+ function* () {
|
|
|
+ const constructor = yield* Socket.WebSocketConstructor
|
|
|
+ return makeDirect({
|
|
|
+ open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
|
|
+ })
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
export interface JsonPrepared {
|
|
|
readonly url: string
|
|
|
readonly headers: Headers.Headers
|
|
|
@@ -294,11 +356,11 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|
|
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
|
|
}
|
|
|
}),
|
|
|
- frames: (prepared, _request, runtime) => {
|
|
|
- const webSocket = runtime.webSocket
|
|
|
+ execute: (prepared, request, _runtime, options) => {
|
|
|
+ const webSocket = options?.webSocket
|
|
|
if (!webSocket) {
|
|
|
- return Stream.fail(
|
|
|
- transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
|
|
+ return Effect.fail(
|
|
|
+ transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
|
|
url: prepared.url,
|
|
|
kind: "websocket",
|
|
|
phase: "prepare",
|
|
|
@@ -306,33 +368,25 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|
|
}),
|
|
|
)
|
|
|
}
|
|
|
- const decoder = new TextDecoder()
|
|
|
- return Stream.unwrap(
|
|
|
- Effect.gen(function* () {
|
|
|
- const connection = yield* Effect.acquireRelease(
|
|
|
- webSocket
|
|
|
- .open({ url: prepared.url, headers: prepared.headers })
|
|
|
- .pipe(
|
|
|
- Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" })),
|
|
|
- ),
|
|
|
- (connection) => connection.close,
|
|
|
- )
|
|
|
- yield* connection.sendText(prepared.message)
|
|
|
- let observed = false
|
|
|
- return connection.messages.pipe(
|
|
|
- Stream.map((message) => {
|
|
|
- observed = true
|
|
|
- return messageText(message, decoder)
|
|
|
+ const driver: WebSocketChannelDriver = {
|
|
|
+ create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
|
|
+ observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
|
|
+ }
|
|
|
+ const exchange: WebSocketChannelExchange = {
|
|
|
+ id: request.id ?? "request",
|
|
|
+ connect: { url: prepared.url, headers: prepared.headers },
|
|
|
+ fallback: () =>
|
|
|
+ Stream.fail(
|
|
|
+ transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
|
|
+ url: prepared.url,
|
|
|
+ kind: "websocket",
|
|
|
+ phase: "fallback",
|
|
|
+ delivery: "not-sent",
|
|
|
}),
|
|
|
- Stream.mapError((error) =>
|
|
|
- annotateTransportError(error, {
|
|
|
- phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
|
|
- delivery: observed ? "accepted" : "ambiguous",
|
|
|
- }),
|
|
|
- ),
|
|
|
- )
|
|
|
- }),
|
|
|
- )
|
|
|
+ ),
|
|
|
+ driver,
|
|
|
+ }
|
|
|
+ return webSocket.execute(exchange)
|
|
|
},
|
|
|
})
|
|
|
|
|
|
@@ -341,15 +395,12 @@ export const jsonTransport = {
|
|
|
with: json,
|
|
|
} as const
|
|
|
|
|
|
-export const WebSocketExecutor = {
|
|
|
- Service,
|
|
|
- layer,
|
|
|
- open,
|
|
|
- fromWebSocket,
|
|
|
- messageText,
|
|
|
-} as const
|
|
|
-
|
|
|
export const WebSocketTransport = {
|
|
|
json,
|
|
|
jsonTransport,
|
|
|
+ direct,
|
|
|
+ makeDirect,
|
|
|
+ open,
|
|
|
+ fromWebSocket,
|
|
|
+ messageText,
|
|
|
} as const
|