Skip to content
Source
Sign inSign up
Open History to see the last commit

OpenCode2Server.ts

175 lines6.4 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2// One OpenCode 2 process serves all directories; share it across instance threads.
3import type { OpenCodeClient } from "@opencode/client/effect";
4import * as Context from "effect/Context";
5import * as SecureRandom from "effect/crypto/SecureRandom";
6import * as Effect from "effect/Effect";
7import * as Base64Url from "effect/encoding/Base64Url";
8import * as P from "effect/Predicate";
9import * as Redacted from "effect/Redacted";
10import * as HttpClientError from "effect/http/HttpClientError";
11​
12import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
13import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
14import * as OpenCode2Client from "./OpenCode2Client.ts";
15​
16const INFO_TIMEOUT_MS = 5_000;
17​
18export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api {
19 readonly url: string;
20 readonly version: string;
21 readonly external: boolean;
22 /** Only owned processes have an authoritative lifetime signal. */
23 readonly processExit?: Effect.Effect<void>;
25​
26export class OpenCode2Server extends Context.Service<
27 OpenCode2Server,
28 {
29 readonly withConnection: <A, E, R>(
30 use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
31 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
32 /** Drops a configured server's verified connection, so the next use reads its version again. */
33 readonly forget: Effect.Effect<void>;
34 }
35>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {}
36​
37/**
38 * OpenCode 2 always requires one and prints a generated one to stdout
39 * otherwise, so T3 supplies its own and keeps it in memory.
40 */
41const generatePassword = Effect.gen(function* () {
42 const random = yield* SecureRandom.SecureRandom;
43 const bytes = yield* random.randomBytes(32);
44 return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
45});
46​
47/** OPENCODE_PASSWORD overrides OPENCODE_SERVER_PASSWORD; drop the inherited 1.x variable. */
48export const serverEnvironment = (
49 environment: NodeJS.ProcessEnv,
50 password: Redacted.Redacted,
51): NodeJS.ProcessEnv => {
52 const { OPENCODE_SERVER_PASSWORD: _inherited, ...rest } = environment;
53 return { ...rest, OPENCODE_PASSWORD: Redacted.value(password) };
54};
55​
56export const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefined => {
57 if (HttpClientError.isHttpClientError(cause)) return cause;
58 if (
59 P.isTagged(cause, "ClientError") &&
60 P.hasProperty(cause, "cause") &&
61 HttpClientError.isHttpClientError(cause.cause)
62 ) {
63 return cause.cause;
64 }
65 return undefined;
66};
67​
68// Empty 401s still reject passwords; undecodable 2xx may be 1.x HTML, not OpenCode 2.
69const describeInfoFailure = (cause: unknown) => {
70 const failure = httpFailureOf(cause);
71 if (failure?.reason._tag === "TransportError") {
72 return "Could not reach the OpenCode server.";
73 }
74 const status = failure?.response?.status;
75 if (status === 401) return "The OpenCode server rejected the server password.";
76 if (status !== undefined && (status < 200 || status >= 300)) {
77 return `The OpenCode server returned HTTP ${status}.`;
78 }
79 return "The server is not an OpenCode 2 server.";
80};
81​
82/** Only server info proves readiness; keep credential-bearing failure details out of client text. */
83export const verifyServer = (client: OpenCodeClient, environment?: NodeJS.ProcessEnv) =>
84 client.server.info().pipe(
85 Effect.timeoutOrElse({
86 duration: resolveProviderProbeTimeoutMs("opencode", INFO_TIMEOUT_MS, environment),
87 orElse: () =>
88 Effect.fail(
89 new OpenCodeRuntime.OpenCodeRuntimeError({
90 operation: "server.info",
91 detail: "Timed out waiting for the OpenCode server.",
92 }),
93 ),
94 }),
95 Effect.catchTags({
96 UnauthorizedError: (cause) =>
97 Effect.fail(
98 new OpenCodeRuntime.OpenCodeRuntimeError({
99 operation: "server.info",
100 detail: "The OpenCode server rejected the server password.",
101 cause,
102 }),
103 ),
104 }),
105 Effect.mapError((cause) =>
106 OpenCodeRuntime.isOpenCodeRuntimeError(cause)
107 ? cause
108 : new OpenCodeRuntime.OpenCodeRuntimeError({
109 operation: "server.info",
110 detail: describeInfoFailure(cause),
111 cause,
112 }),
113 ),
114 Effect.map((info) => info.version),
115 );
116​
117export const make = Effect.fn("OpenCode2Server.make")(function* (input: {
118 readonly binaryPath: string;
119 readonly serverUrl: string;
120 readonly serverPassword: string;
121 readonly directory: string;
122 readonly environment: NodeJS.ProcessEnv;
123}) {
124 const opencode = yield* OpenCode2Client.OpenCode2Client;
125 const connectTo = Effect.fn("OpenCode2Server.connectTo")(function* (
126 url: string,
127 password: Redacted.Redacted,
128 external: boolean,
129 ) {
130 const api = yield* opencode.connect({ baseUrl: url, password });
131 const version = yield* verifyServer(api.client, input.environment);
132 return { ...api, url, version, external } satisfies OpenCode2Connection;
133 });
134 let latest: OpenCode2Connection | undefined;
135 const remember = (connection: OpenCode2Connection) =>
136 Effect.sync(() => {
137 latest = connection;
138 return connection.version;
139 });
140​
141 const serverUrl = input.serverUrl.trim();
142 if (serverUrl.length > 0) {
143 const connect = connectTo(serverUrl, Redacted.make(input.serverPassword), true).pipe(
144 Effect.tap(remember),
145 );
146 return OpenCode2Server.of({
147 withConnection: Effect.fn("OpenCode2Server.withConnection")(function (use) {
148 return Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
149 Effect.flatMap(use),
150 );
151 }),
152 forget: Effect.sync(() => {
153 latest = undefined;
154 }),
155 });
156 }
157​
158 const password = yield* generatePassword;
159 const owner = yield* OpenCodeServerOwner.make({
160 binaryPath: input.binaryPath,
161 directory: input.directory,
162 environment: serverEnvironment(input.environment, password),
163 verify: (url) => connectTo(url, password, false).pipe(Effect.flatMap(remember)),
164 });
165 return OpenCode2Server.of({
166 withConnection: Effect.fn("OpenCode2Server.withConnection")(function (use) {
167 return owner.withServer((server) =>
168 latest?.url === server.url
169 ? use({ ...latest, processExit: server.exitCode.pipe(Effect.asVoid) })
170 : Effect.die(new Error("OpenCode 2 server was lent before verification.")),
171 );
172 }),
173 forget: Effect.void,
174 });
175});