Repository navigation
Expand file tree
/
Copy pathOpenCode2Server.ts
More file actions
186 lines (173 loc) · 6.39 KB
/
Copy pathOpenCode2Server.ts
File metadata and controls
186 lines (173 loc) · 6.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
/**
* A 2.x server serves every location from one process, so an instance shares
* one server across all of its threads and directories.
*/
import type { OpenCodeClient } from "@opencode/client/effect";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as Layer from "effect/Layer";
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: <A, E, R>(
use: (connection: OpenCode2Connection) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
}
>()("@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.
*/
export const generatePassword = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const bytes = yield* crypto.randomBytes(32).pipe(Effect.orDie);
return Redacted.make(Base64Url.encode(bytes), { label: "OPENCODE_PASSWORD" });
});
/**
* `OPENCODE_PASSWORD` wins over `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so
* the inherited 1.x variable is dropped to keep the T3 password the only one in
* play.
*/
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) };
};
/** The client wraps HTTP failures in a `ClientError`; this unwraps either shape. */
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;
};
/**
* Only a transport failure means the server is unreachable; a 401 without the
* 2.x error body is still a rejected password (1.x sends it empty), and any
* other failed status is a server error. A 2xx the client cannot decode is not
* OpenCode 2 (1.x answers with HTML).
*/
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.";
};
/**
* `/health` and friends answer 200 with the web UI's HTML on every version, so
* only this endpoint proves readiness. Details are fixed text because they
* reach clients and a `serverUrl` can carry credentials; the underlying failure
* stays in `cause`.
*/
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: (use) =>
Effect.suspend(() => (latest === undefined ? connect : Effect.succeed(latest))).pipe(
Effect.flatMap(use),
),
});
}
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: (use) =>
owner.withServer((server) =>
latest?.url === server.url
? use(latest)
: Effect.die(new Error("OpenCode 2 server was lent before verification.")),
),
});
});
export const layer = (input: Parameters<typeof make>[0]) =>
Layer.effect(OpenCode2Server, make(input));