// One OpenCode 2 process serves all directories; share it across instance threads.
import type { OpenCodeClient } from "@opencode/client/effect";
import * as Context from "effect/Context";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as P from "effect/Predicate";
import * as Redacted from "effect/Redacted";
import * as HttpClientError from "effect/http/HttpClientError";
import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
import * as OpenCode2Client from "./OpenCode2Client.ts";
const INFO_TIMEOUT = "5 seconds";
export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api {
readonly url: string;
readonly version: string;
readonly external: boolean;
}
export class OpenCode2Server extends Context.Service<
OpenCode2Server,
{
readonly withConnection: (
use: (connection: OpenCode2Connection) => Effect.Effect,
) => Effect.Effect;
/** Drops a configured server's verified connection, so the next use reads its version again. */
readonly forget: Effect.Effect;
}
>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}
/**
* OpenCode 2 always requires one and prints a generated one to stdout
* otherwise, so T3 supplies its own and keeps it in memory.
*/
const generatePassword = Effect.gen(function* () {
const random = yield* SecureRandom.SecureRandom;
const bytes = yield* random.randomBytes(32);
return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
});
/** OPENCODE_PASSWORD overrides OPENCODE_SERVER_PASSWORD; drop the inherited 1.x variable. */
export const serverEnvironment = (
environment: NodeJS.ProcessEnv,
password: Redacted.Redacted,
): NodeJS.ProcessEnv => {
const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment;
return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) };
};
const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => {
if (HttpClientError.isHttpClientError(cause)) return cause;
if (
P.isTagged(cause, "ClientError") &&
P.hasProperty(cause, "cause") &&
HttpClientError.isHttpClientError(cause.cause)
) {
return cause.cause;
}
return undefined;
};
// Empty 401s still reject passwords; undecodable 2xx may be 1.x HTML, not OpenCode 2.
const describeInfoFailure = (cause: unknown) => {
const failure = httpFailureOf(cause);
if (failure?.reason._tag === "TransportError") {
return "Could not reach the OpenCode server.";
}
const status = failure?.response?.status;
if (status === 401) return "The OpenCode server rejected the server password.";
if (status !== undefined && (status < 200 || status >= 300)) {
return `The OpenCode server returned HTTP ${status}.`;
}
return "The server is not an OpenCode 2 server.";
};
/** Only server info proves readiness; keep credential-bearing failure details out of client text. */
export const verifyServer = (client: OpenCodeClient) =>
client.server.info().pipe(
Effect.timeoutOrElse({
duration: INFO_TIMEOUT,
orElse: () =>
Effect.fail(
new OpenCodeRuntime.OpenCodeRuntimeError({
operation: "server.info",
detail: "Timed out waiting for the OpenCode server.",
}),
),
}),
Effect.catchTags({
UnauthorizedError: (cause) =>
Effect.fail(
new OpenCodeRuntime.OpenCodeRuntimeError({
operation: "server.info",
detail: "The OpenCode server rejected the server password.",
cause,
}),
),
}),
Effect.mapError((cause) =>
OpenCodeRuntime.isOpenCodeRuntimeError(cause)
? cause
: new OpenCodeRuntime.OpenCodeRuntimeError({
operation: "server.info",
detail: describeInfoFailure(cause),
cause,
}),
),
Effect.map((info) => info.version),
);
export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
readonly binaryPath: string;
readonly serverUrl: string;
readonly serverPassword: string;
readonly directory: string;
readonly environment: NodeJS.ProcessEnv;
}) {
const opencode = yield* OpenCode2Client.OpenCode2Client;
const connectTo = Effect.fn("OpenCode2Server.connectTo")(function* (
url: string,
password: Redacted.Redacted,
external: boolean,
) {
const api = yield* opencode.connect({ baseUrl: url, password });
const version = yield* verifyServer(api.client);
return { ...api, url, version, external } satisfies OpenCode2Connection;
});
let latest: OpenCode2Connection | undefined;
const remember = (connection: OpenCode2Connection) =>
Effect.sync(() => {
latest = connection;
return connection.version;
});
const serverUrl = input.serverUrl.trim();
if (serverUrl.length > 0) {
const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe(
Effect.tap(remember),
);
return OpenCode2Server.of({
withConnection: Effect.fn("OpenCode2Server.withConnection")(function (use) {
return Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
Effect.flatMap(use),
);
}),
forget: Effect.sync(() => {
latest = undefined;
}),
});
}
const password = yield* generatePassword;
const owner = yield* OpenCodeServerOwner.make({
binaryPath: input.binaryPath,
directory: input.directory,
environment: serverEnvironment(input.environment, password),
verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)),
});
return OpenCode2Server.of({
withConnection: Effect.fn("OpenCode2Server.withConnection")(function (use) {
return owner.withServer((server) =>
latest?.url === server.url
? use(latest)
: Effect.die(new Error("OpenCode 2 server was lent before verification.")),
);
}),
forget: Effect.void,
});
});