-
Notifications
You must be signed in to change notification settings - Fork 179
Expand file tree
/
Copy pathmain.ts
More file actions
173 lines (157 loc) · 7.23 KB
/
Copy pathmain.ts
File metadata and controls
173 lines (157 loc) · 7.23 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
import { Context, Data, Effect, Layer, ManagedRuntime } from "effect";
import { createExecutionEngine } from "@executor-js/execution";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { makeLocalApiHandler } from "./app";
import { disposeExecutor, getExecutorBundle } from "./executor";
import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
// ---------------------------------------------------------------------------
// Local server handlers.
//
// The typed plugin `/api` is assembled by `ExecutorApp.make` (see `./app.ts`):
// the same shared facade cloud and self-host use, slotting local's single-user
// identity + the ONE boot executor (the `fixedExecution` seam) + console error
// capture + Swagger. The plugin set is the union of `executor.config.ts`
// (static, typed) and `executor.jsonc#plugins` (dynamic, jiti-loaded), resolved
// inside the boot bundle, so the composition happens after the bundle resolves
// rather than at module-eval time.
//
// The in-process `/mcp` surface stays local-platform: a single-engine handler
// over the SAME boot executor with a browser-approval store + stdio transport
// (not the shared multi-user `McpServingRoutes` envelope), built here and routed
// by the Bun shell in `serve.ts`.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Server handlers
// ---------------------------------------------------------------------------
export type ServerHandlers = {
readonly api: {
readonly handler: (request: Request) => Promise<Response>;
readonly dispose: () => Promise<void>;
};
readonly mcp: McpRequestHandler;
};
class ServerHandlersDisposeError extends Data.TaggedError("ServerHandlersDisposeError")<{
readonly operation: "api.dispose" | "mcp.close" | "disposeExecutor" | "runtime.dispose";
readonly cause: unknown;
}> {}
const ignoreDisposeFailure = (
operation: ServerHandlersDisposeError["operation"],
dispose: () => Promise<unknown>,
) =>
Effect.tryPromise({
try: dispose,
catch: (cause) => new ServerHandlersDisposeError({ operation, cause }),
}).pipe(Effect.ignore);
const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => {
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.all(
[
ignoreDisposeFailure("api.dispose", () => handlers.api.dispose()),
ignoreDisposeFailure("mcp.close", () => handlers.mcp.close()),
],
{ concurrency: "unbounded" },
);
// The API/MCP handlers borrow the shared local executor handle. Release it
// after the surfaces are closed so server shutdown (and failed startup
// cleanup via disposeServerHandlers) releases the owned data-dir lock.
yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
}),
);
};
export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {
let apiHandler: ServerHandlers["api"] | null = null;
let mcp: McpRequestHandler | null = null;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: handler boot owns the shared executor; partial startup failure must release it before rethrowing
try {
// The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
// boot bearer token is the authoritative `/api` gate (see `identity.ts`).
apiHandler = await makeLocalApiHandler(token);
// The in-process MCP server runs over the SAME boot executor, with its own
// engine instance (the browser-approval + stdio surface is local-only and not
// part of the shared API). Reuse the shared boot bundle so the MCP executor is
// byte-identical to the one the API serves.
const bundle = await getExecutorBundle();
const engine = createExecutionEngine({
executor: bundle.executor,
codeExecutor: makeQuickJsExecutor(),
});
mcp = createMcpRequestHandler({
defaultConfig: { engine },
createConfigForResource: async (resource) => {
if (resource.kind === "default") return { config: { engine } };
// A toolkit resource differs from the default only in its plugin scope,
// so it derives from the boot bundle's ALREADY-OPEN database. Opening a
// second owned database here would deadlock against the data-dir
// ownership lock this very process holds, failing every request.
const scoped = await bundle.createScopedExecutor({
activeToolkitSlug: resource.slug,
});
const toolkitEngine = createExecutionEngine({
executor: scoped.executor,
codeExecutor: makeQuickJsExecutor(),
});
return {
config: { engine: toolkitEngine },
close: scoped.dispose,
};
},
});
return { api: apiHandler, mcp };
} catch (cause) {
const partialApiHandler = apiHandler;
const partialMcp = mcp;
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.all(
[
partialApiHandler
? ignoreDisposeFailure("api.dispose", () => partialApiHandler.dispose())
: Effect.void,
partialMcp ? ignoreDisposeFailure("mcp.close", () => partialMcp.close()) : Effect.void,
],
{ concurrency: "unbounded" },
);
yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
}),
);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async factory must reject with the original handler boot failure after cleanup
throw cause;
}
};
export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(
"@executor-js/local/ServerHandlersService",
) {}
// The handlers are built once per process and memoized. The boot token is
// captured on the first call (serve.ts / the vite dev middleware both pass the
// SAME token loaded from `auth.json`), so memoization on first-call is correct.
let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<ServerHandlersService, never> | null =
null;
const getServerHandlersRuntime = (
token: string,
): ManagedRuntime.ManagedRuntime<ServerHandlersService, never> => {
if (serverHandlersRuntime) return serverHandlersRuntime;
const layer = Layer.effect(ServerHandlersService)(
Effect.acquireRelease(
Effect.promise(() => createServerHandlers(token)),
(handlers) => Effect.promise(() => closeServerHandlers(handlers)),
),
);
serverHandlersRuntime = ManagedRuntime.make(layer);
return serverHandlersRuntime;
};
export const getServerHandlers = (token: string): Promise<ServerHandlers> =>
getServerHandlersRuntime(token).runPromise(ServerHandlersService.asEffect());
export const disposeServerHandlers = async (): Promise<void> => {
const runtime = serverHandlersRuntime;
if (!runtime) return;
serverHandlersRuntime = null;
await Effect.runPromise(
Effect.gen(function* () {
yield* ignoreDisposeFailure("runtime.dispose", () => runtime.dispose());
// Belt-and-suspenders: runtime disposal normally reaches closeServerHandlers,
// but a failed runtime finalizer must not leave the shared DB owner alive.
yield* ignoreDisposeFailure("disposeExecutor", () => disposeExecutor());
}),
);
};