1/**
2 * The OpenCode 2 server behind one provider instance. A 2.x server serves every
3 * location from one process, so an instance shares one server across all of its
4 * threads and directories.
5 *
6 * @module provider/opencode2/OpenCode2Server
7 */
8import type { OpenCodeClient } from "@opencode/client/effect";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Layer from "effect/Layer";
14import * as P from "effect/Predicate";
15import * as Redacted from "effect/Redacted";
16import * as HttpClientError from "effect/http/HttpClientError";
17
18import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
19import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
20import * as OpenCode2Client from "./OpenCode2Client.ts";
21
22const INFO_TIMEOUT = "5 seconds";
23
24export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api {
25 readonly url: string;
26 readonly version: string;
27 readonly external: boolean;
28}
29
30export class OpenCode2Server extends Context.Service<
31 OpenCode2Server,
32 {
33 /** Runs `use` against the instance's server, spawning it first when T3 owns it. */
34 readonly withConnection: <A, E, R>(
35 use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
36 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
37 }
38>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}
39
40/**
41 * A fresh password for a spawned server. OpenCode 2 always requires one and
42 * prints a generated one to stdout otherwise, so T3 supplies its own and keeps
43 * it in memory.
44 */
45export const generatePassword = Effect.gen(function* () {
46 const crypto = yield* Crypto.Crypto;
47 const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie);
48 return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
49});
50
51/**
52 * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over
53 * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is
54 * dropped to keep the T3 password the only one in play.
55 */
56export const serverEnvironment = (
57 environment: NodeJS.ProcessEnv,
58 password: Redacted.Redacted,
59): NodeJS.ProcessEnv => {
60 const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment;
61 return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) };
62};
63
64/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */
65const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => {
66 if (HttpClientError.isHttpClientError(cause)) return cause;
67 if (
68 P.isTagged(cause, "ClientError") &&
69 P.hasProperty(cause, "cause") &&
70 HttpClientError.isHttpClientError(cause.cause)
71 ) {
72 return cause.cause;
73 }
74 return undefined;
75};
76
77/**
78 * Describes a failed `/api/info` call. Only a transport failure means the
79 * server is unreachable; a 401 without the 2.x error body is still a rejected
80 * password (1.x sends it empty), and any other failed status is a server error.
81 * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML).
82 */
83const describeInfoFailure = (cause: unknown) => {
84 const failure = httpFailureOf(cause);
85 if (failure?.reason._tag === "TransportError") {
86 return "Could not reach the OpenCode server.";
87 }
88 const status = failure?.response?.status;
89 if (status === 401) return "The OpenCode server rejected the server password.";
90 if (status !== undefined && (status < 200 || status >= 300)) {
91 return `The OpenCode server returned HTTP ${status}.`;
92 }
93 return "The server is not an OpenCode 2 server.";
94};
95
96/**
97 * Confirms a server is an authenticated OpenCode 2 server through `/api/info`
98 * and returns its version. `/health` and friends answer 200 with the web UI's
99 * HTML on every version, so only this endpoint proves readiness. Details are
100 * fixed text because they reach clients and a `serverUrl` can carry
101 * credentials; the underlying failure stays in `cause`.
102 */
103export const verifyServer = (client: OpenCodeClient) =>
104 client.server.info().pipe(
105 Effect.timeoutOrElse({
106 duration: INFO_TIMEOUT,
107 orElse: () =>
108 Effect.fail(
109 new OpenCodeRuntime.OpenCodeRuntimeError({
110 operation: "server.info",
111 detail: "Timed out waiting for the OpenCode server.",
112 }),
113 ),
114 }),
115 Effect.catchTags({
116 UnauthorizedError: (cause) =>
117 Effect.fail(
118 new OpenCodeRuntime.OpenCodeRuntimeError({
119 operation: "server.info",
120 detail: "The OpenCode server rejected the server password.",
121 cause,
122 }),
123 ),
124 }),
125 Effect.mapError((cause) =>
126 OpenCodeRuntime.isOpenCodeRuntimeError(cause)
127 ? cause
128 : new OpenCodeRuntime.OpenCodeRuntimeError({
129 operation: "server.info",
130 detail: describeInfoFailure(cause),
131 cause,
132 }),
133 ),
134 Effect.map((info) => info.version),
135 );
136
137/**
138 * One server per provider instance. With a `serverUrl` it connects to that
139 * server with the configured password; otherwise it spawns `binaryPath serve`
140 * with a generated password through {@link OpenCodeServerOwner}, which shares
141 * the process between borrowers and stops it after an idle period. Clients are
142 * built once per server; a failed check is not remembered.
143 */
144export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
145 readonly binaryPath: string;
146 readonly serverUrl: string;
147 readonly serverPassword: string;
148 readonly directory: string;
149 readonly environment: NodeJS.ProcessEnv;
150}) {
151 const opencode = yield* OpenCode2Client.OpenCode2Client;
152 const connectTo = (url: string, password: Redacted.Redacted, external: boolean) =>
153 Effect.gen(function* () {
154 const api = yield* opencode.connect({ baseUrl: url, password });
155 const version = yield* verifyServer(api.client);
156 return { ...api, url, version, external } satisfies OpenCode2Connection;
157 });
158 let latest: OpenCode2Connection | undefined;
159 const remember = (connection: OpenCode2Connection) =>
160 Effect.sync(() => {
161 latest = connection;
162 return connection.version;
163 });
164
165 const serverUrl = input.serverUrl.trim();
166 if (serverUrl.length > 0) {
167 const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe(
168 Effect.tap(remember),
169 );
170 return OpenCode2Server.of({
171 withConnection: (use) =>
172 Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
173 Effect.flatMap(use),
174 ),
175 });
176 }
177
178 const password = yield* generatePassword;
179 const owner = yield* OpenCodeServerOwner.make({
180 binaryPath: input.binaryPath,
181 directory: input.directory,
182 environment: serverEnvironment(input.environment, password),
183 verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)),
184 });
185 return OpenCode2Server.of({
186 withConnection: (use) =>
187 owner.withServer((server) =>
188 // The owner verifies every server it starts before lending it out.
189 latest?.url === server.url
190 ? use(latest)
191 : Effect.die(new Error("OpenCode 2 server was lent before verification.")),
192 ),
193 });
194});
195
196/** Built once per provider instance from its settings; closing it stops a spawned server. */
197export const layer = (input: Parameters<typeof make>[0]) =>
198 Layer.effect(OpenCode2Server, make(input));