1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Scope from "effect/Scope";
7import * as Semaphore from "effect/Semaphore";
8
9import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
10
11const OPENCODE_SERVER_IDLE_TTL = "30 seconds";
12
13interface OpenCodeServerOwnerState {
14 server: OpenCodeRuntime.OpenCodeServerProcess | null;
15 serverScope: Scope.Closeable | null;
16 borrowers: number;
17 idleCloseFiber: Fiber.Fiber<void, never> | null;
18}
19
20export class OpenCodeServerOwner extends Context.Service<
21 OpenCodeServerOwner,
22 {
23 readonly withServer: <A, E, R>(
24 use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect<A, E, R>,
25 ) => Effect.Effect<A, E | OpenCodeRuntime.OpenCodeRuntimeError, R>;
26 }
27>()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {}
28
29/** Owns the lazy local OpenCode server shared by one provider instance. */
30export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: {
31 readonly binaryPath: string;
32 readonly directory: string;
33 readonly serverPassword?: string;
34 readonly environment?: NodeJS.ProcessEnv;
35 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
36}) {
37 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
38 const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
39 Scope.close(scope, Exit.void),
40 );
41 const mutex = yield* Semaphore.make(1);
42 const state: OpenCodeServerOwnerState = {
43 server: null,
44 serverScope: null,
45 borrowers: 0,
46 idleCloseFiber: null,
47 };
48
49 const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () {
50 const fiber = state.idleCloseFiber;
51 state.idleCloseFiber = null;
52 if (fiber !== null) {
53 yield* Fiber.interrupt(fiber).pipe(Effect.ignore);
54 }
55 });
56
57 const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* (
58 expected?: OpenCodeRuntime.OpenCodeServerProcess,
59 ) {
60 if (expected !== undefined && state.server !== expected) {
61 return;
62 }
63 const scope = state.serverScope;
64 state.server = null;
65 state.serverScope = null;
66 if (scope !== null) {
67 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
68 }
69 });
70
71 const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* (
72 server: OpenCodeRuntime.OpenCodeServerProcess,
73 ) {
74 yield* server.exitCode;
75 yield* mutex.withPermit(
76 Effect.gen(function* () {
77 if (state.server !== server) {
78 return;
79 }
80 yield* cancelIdleClose();
81 yield* closeServer(server);
82 }),
83 );
84 });
85
86 const acquireServer = mutex.withPermit(
87 Effect.gen(function* () {
88 yield* cancelIdleClose();
89 if (state.server !== null) {
90 if (yield* state.server.isRunning) {
91 state.borrowers += 1;
92 return state.server;
93 }
94 yield* closeServer(state.server);
95 }
96
97 return yield* Effect.uninterruptibleMask((restore) =>
98 Effect.gen(function* () {
99 const serverScope = yield* Scope.make();
100 const started = yield* Effect.exit(
101 restore(
102 runtime
103 .startOpenCodeServerProcess({
104 binaryPath: input.binaryPath,
105 directory: input.directory,
106 ...(input.serverPassword !== undefined
107 ? { serverPassword: input.serverPassword }
108 : {}),
109 ...(input.environment ? { environment: input.environment } : {}),
110 ...(input.verify ? { verify: input.verify } : {}),
111 })
112 .pipe(Effect.provideService(Scope.Scope, serverScope)),
113 ),
114 );
115 if (Exit.isFailure(started)) {
116 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
117 return yield* Effect.failCause(started.cause);
118 }
119
120 const server = started.value;
121 state.server = server;
122 state.serverScope = serverScope;
123 state.borrowers = 1;
124 yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope));
125 return server;
126 }),
127 );
128 }),
129 );
130
131 const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
132 mutex.withPermit(
133 Effect.gen(function* () {
134 if (state.server !== server) {
135 return;
136 }
137 state.borrowers = Math.max(0, state.borrowers - 1);
138 if (state.borrowers > 0) {
139 return;
140 }
141 yield* cancelIdleClose();
142 state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe(
143 Effect.andThen(
144 mutex.withPermit(
145 Effect.gen(function* () {
146 if (state.server !== server || state.borrowers > 0) {
147 return;
148 }
149 state.idleCloseFiber = null;
150 yield* closeServer(server);
151 }),
152 ),
153 ),
154 Effect.forkIn(ownerScope),
155 );
156 }),
157 );
158
159 yield* Effect.addFinalizer(() =>
160 mutex.withPermit(
161 Effect.gen(function* () {
162 yield* cancelIdleClose();
163 state.borrowers = 0;
164 yield* closeServer();
165 }),
166 ),
167 );
168
169 return OpenCodeServerOwner.of({
170 withServer: (use) =>
171 Effect.uninterruptibleMask((restore) =>
172 restore(acquireServer).pipe(
173 Effect.flatMap((server) =>
174 restore(use(server)).pipe(Effect.ensuring(releaseServer(server))),
175 ),
176 ),
177 ),
178 });
179});
180
181/** @public Service construction is part of the canonical Effect module API. */
182export const layer = (input: {
183 readonly binaryPath: string;
184 readonly directory: string;
185 readonly serverPassword?: string;
186 readonly environment?: NodeJS.ProcessEnv;
187 readonly verify?: (url: string) => Effect.Effect<string, OpenCodeRuntime.OpenCodeRuntimeError>;
188}) => Layer.effect(OpenCodeServerOwner, make(input));