packages/client-runtime/src/rpc/session.ts
import { type ServerConfig, WS_METHODS } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schedule from "effect/Schedule";
import type * as Scope from "effect/Scope";
import * as RpcClient from "effect/unstable/rpc/RpcClient";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as Socket from "effect/unstable/socket/Socket";
import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts";
import type {
ConnectionAttemptError,
ConnectionTransientError,
PreparedConnection,
} from "../connection/model.ts";
import {
ConnectionBlockedError,
ConnectionTransientError as ConnectionTransientErrorClass,
} from "../connection/model.ts";
const SOCKET_OPEN_TIMEOUT = "15 seconds";
export interface RpcSession {
readonly client: WsRpcProtocolClient;
readonly initialConfig: Effect.Effect<ServerConfig, ConnectionAttemptError>;
readonly ready: Effect.Effect<void, ConnectionAttemptError>;
readonly probe: Effect.Effect<void, ConnectionAttemptError>;
readonly closed: Effect.Effect<never, ConnectionTransientError>;
}
export class RpcSessionFactory extends Context.Service<
RpcSessionFactory,
{
readonly connect: (
connection: PreparedConnection,
) => Effect.Effect<RpcSession, ConnectionAttemptError, Scope.Scope>;
}
>()("@t3tools/client-runtime/rpc/session/RpcSessionFactory") {}
type InitialConfigError = Effect.Error<
ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]>
>;
type ProbeError = Effect.Error<ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverProbe]>>;
function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError {
switch (error._tag) {
case "EnvironmentAuthorizationError":
return new ConnectionBlockedError({
reason: "permission",
detail: error.message,
});
case "KeybindingsConfigParseError":
case "ServerSettingsError":
return new ConnectionTransientErrorClass({
reason: "remote-unavailable",
detail: error.message,
});
case "RpcClientError":
return new ConnectionTransientErrorClass({
reason: "transport",
detail: error.message,
});
}
}
export const make = Effect.gen(function* () {
const webSocketConstructor = yield* Socket.WebSocketConstructor;
const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
yield* Effect.annotateCurrentSpan({
"connection.environment.id": connection.environmentId,
});
const connected = yield* Deferred.make<void>();
const disconnected = yield* Deferred.make<never, ConnectionTransientError>();
const hooks = RpcClient.ConnectionHooks.of({
onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid),
onDisconnect: Deferred.isDone(connected).pipe(
Effect.flatMap((wasConnected) =>
Deferred.fail(
disconnected,
new ConnectionTransientErrorClass({
reason: "transport",
detail: wasConnected
? `${connection.label} disconnected.`
: `${connection.label} could not establish a WebSocket connection.`,
}),
),
),
Effect.asVoid,
),
});
const socketLayer = Socket.layerWebSocket(connection.socketUrl, {
openTimeout: SOCKET_OPEN_TIMEOUT,
}).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)));
const protocolLayer = Layer.effect(
RpcClient.Protocol,
RpcClient.makeProtocolSocket({
retryTransientErrors: false,
retryPolicy: Schedule.recurs(0),
}),
).pipe(
Layer.provide(
Layer.mergeAll(
socketLayer,
RpcSerialization.layerJson,
Layer.succeed(RpcClient.ConnectionHooks, hooks),
),
),
);
const protocolContext = yield* Layer.build(protocolLayer).pipe(
Effect.withSpan("environment.websocket.connect"),
);
const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext));
const initialConfig = yield* Effect.cached(
client[WS_METHODS.serverGetConfig]({}).pipe(
Effect.mapError(mapSessionRpcError),
Effect.withSpan("environment.initialSync"),
),
);
const probe = initialConfig.pipe(
Effect.flatMap((config) =>
(config.environment.capabilities.connectionProbe === true
? client[WS_METHODS.serverProbe]({})
: client[WS_METHODS.serverGetConfig]({})
).pipe(Effect.mapError(mapSessionRpcError)),
),
Effect.asVoid,
Effect.withSpan("clientRuntime.connection.rpcSession.probe"),
);
return {
client,
initialConfig,
ready: Deferred.await(connected).pipe(
Effect.andThen(initialConfig),
Effect.asVoid,
Effect.raceFirst(Deferred.await(disconnected)),
),
probe,
closed: Deferred.await(disconnected),
} satisfies RpcSession;
});
return RpcSessionFactory.of({ connect });
});
export const layer = Layer.effect(RpcSessionFactory, make);