History

feat: API contract, server handler, and frontend with effect-atom

- packages/core/api: HttpApi contract with ToolsApi + SecretsApi groups SDK tagged errors pass through directly with status annotations (ToolNotFoundError→404, PolicyDeniedError→403, etc.) - apps/server: API handler implementation via HttpApiBuilder Exports createApiHandler() for reuse (no baked-in server runtime) Handlers are pure pass-through — no manual error catching - apps/web: Vite + React frontend served on same port as API Vite plugin forwards /v1/*, /docs, /openapi.json to Effect handler effect-atom integration: AtomHttpApi.Tag typed client, query/mutation atoms RegistryProvider + useAtomValue with Result pattern matching - Catalog pins @effect/platform, platform-bun, platform-node, cluster - All 9 packages typecheck clean

Rhys Sullivan committed 7 months ago

Commit 8babea81 parent 00219a926 files · +1022 −32Browse files

Showing the changes to apps (16 of 26 files). Show all

Unified · Split

16 files
  1. apps/server/package.json +24 −0
  2. apps/server/src/handlers/secrets.ts +63 −0
  3. apps/server/src/handlers/tools.ts +37 −0
  4. apps/server/src/index.ts +2 −0
  5. apps/server/src/main.ts +40 −0
  6. apps/server/src/services/executor.ts +22 −0
  7. apps/server/tsconfig.json +22 −0
  8. apps/web/index.html +12 −0
  9. apps/web/package.json +30 −0
  10. apps/web/src/App.tsx +46 −0
  11. apps/web/src/api/atoms.ts +40 −0
  12. apps/web/src/api/client.ts +15 −0
  13. apps/web/src/api/index.ts +10 −0
  14. apps/web/src/main.tsx +6 −0
  15. apps/web/tsconfig.json +19 −0
  16. apps/web/vite.config.ts +67 −0
apps/server/package.json+24−0
@@ -0,0 +1,24 @@
1{
2 "name": "@executor/server",
3 "version": "0.0.0",
4 "private": true,
5 "type": "module",
6 "exports": {
7 ".": "./src/index.ts"
8 },
9 "dependencies": {
10 "@effect/platform": "catalog:",
11
12 "@executor/api": "workspace:*",
13 "@executor/sdk": "workspace:*",
14 "effect": "catalog:"
15 },
16 "devDependencies": {
17 "@types/node": "catalog:",
18 "bun-types": "catalog:",
19 "typescript": "^5.9.3"
20 },
21 "scripts": {
22 "typecheck": "tsc --noEmit"
23 }
24}
apps/server/src/handlers/secrets.ts+63−0
@@ -0,0 +1,63 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Effect, Option } from "effect";
3import type { SecretId, ScopeId } from "@executor/sdk";
4
5import { ExecutorApi } from "@executor/api";
6import { ExecutorService } from "../services/executor";
7
8const refToResponse = (ref: {
9 id: SecretId;
10 scopeId: ScopeId;
11 name: string;
12 provider: Option.Option<string>;
13 purpose?: string;
14 createdAt: Date;
15}) => ({
16 id: ref.id,
17 scopeId: ref.scopeId,
18 name: ref.name,
19 provider: Option.getOrUndefined(ref.provider),
20 purpose: ref.purpose,
21 createdAt: ref.createdAt.getTime(),
22});
23
24export const SecretsHandlers = HttpApiBuilder.group(
25 ExecutorApi,
26 "secrets",
27 (handlers) =>
28 handlers
29 .handle("list", ({ path }) =>
30 Effect.gen(function* () {
31 const executor = yield* ExecutorService;
32 const refs = yield* executor.secrets.list();
33 return refs.map(refToResponse);
34 }),
35 )
36 .handle("status", ({ path }) =>
37 Effect.gen(function* () {
38 const executor = yield* ExecutorService;
39 const status = yield* executor.secrets.status(path.secretId);
40 return { secretId: path.secretId, status };
41 }),
42 )
43 .handle("set", ({ path, payload }) =>
44 Effect.gen(function* () {
45 const executor = yield* ExecutorService;
46 const ref = yield* executor.secrets.set({
47 id: payload.id,
48 name: payload.name,
49 value: payload.value,
50 purpose: payload.purpose,
51 provider: payload.provider,
52 });
53 return refToResponse(ref);
54 }),
55 )
56 .handle("remove", ({ path }) =>
57 Effect.gen(function* () {
58 const executor = yield* ExecutorService;
59 const removed = yield* executor.secrets.remove(path.secretId);
60 return { removed };
61 }),
62 ),
63);
apps/server/src/handlers/tools.ts+37−0
@@ -0,0 +1,37 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Effect } from "effect";
3
4import { ExecutorApi } from "@executor/api";
5import { ExecutorService } from "../services/executor";
6
7export const ToolsHandlers = HttpApiBuilder.group(
8 ExecutorApi,
9 "tools",
10 (handlers) =>
11 handlers
12 .handle("list", ({ path }) =>
13 Effect.gen(function* () {
14 const executor = yield* ExecutorService;
15 const tools = yield* executor.tools.list();
16 return tools.map((t) => ({
17 id: t.id,
18 name: t.name,
19 description: t.description,
20 tags: [...t.tags],
21 mayElicit: t.mayElicit,
22 }));
23 }),
24 )
25 .handle("schema", ({ path }) =>
26 Effect.gen(function* () {
27 const executor = yield* ExecutorService;
28 return yield* executor.tools.schema(path.toolId);
29 }),
30 )
31 .handle("invoke", ({ path, payload }) =>
32 Effect.gen(function* () {
33 const executor = yield* ExecutorService;
34 return yield* executor.tools.invoke(path.toolId, payload.args);
35 }),
36 ),
37);
apps/server/src/index.ts+2−0
@@ -0,0 +1,2 @@
1export { createApiHandler, type ApiHandler } from "./main";
2export { ExecutorServiceLive } from "./services/executor";
apps/server/src/main.ts+40−0
@@ -0,0 +1,40 @@
1import {
2 HttpApiBuilder,
3 HttpApiSwagger,
4 HttpMiddleware,
5 HttpServer,
6} from "@effect/platform";
7import { Layer } from "effect";
8
9import { ExecutorApi } from "@executor/api";
10import { ToolsHandlers } from "./handlers/tools";
11import { SecretsHandlers } from "./handlers/secrets";
12import { ExecutorServiceLive } from "./services/executor";
13
14// ---------------------------------------------------------------------------
15// API layer — wire handlers
16// ---------------------------------------------------------------------------
17
18const ApiLive = HttpApiBuilder.api(ExecutorApi).pipe(
19 Layer.provide([ToolsHandlers, SecretsHandlers]),
20 Layer.provide(ExecutorServiceLive),
21);
22
23// ---------------------------------------------------------------------------
24// Web handler — usable by Vite plugin, standalone server, tests, etc.
25// ---------------------------------------------------------------------------
26
27export const createApiHandler = () =>
28 HttpApiBuilder.toWebHandler(
29 HttpApiSwagger.layer().pipe(
30 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
31 Layer.provideMerge(HttpApiBuilder.middlewareCors()),
32 Layer.provideMerge(ApiLive),
33 Layer.provideMerge(HttpServer.layerContext),
34 ),
35 { middleware: HttpMiddleware.logger },
36 );
37
38export type ApiHandler = ReturnType<typeof createApiHandler>;
39
40export { ExecutorServiceLive } from "./services/executor";
apps/server/src/services/executor.ts+22−0
@@ -0,0 +1,22 @@
1import { Context, Effect, Layer } from "effect";
2import { createExecutor, makeTestConfig } from "@executor/sdk";
3
4import type { Executor } from "@executor/sdk";
5
6// ---------------------------------------------------------------------------
7// Service tag — provides the executor instance to HTTP handlers
8// ---------------------------------------------------------------------------
9
10export class ExecutorService extends Context.Tag("ExecutorService")<
11 ExecutorService,
12 Executor
13>() {}
14
15// ---------------------------------------------------------------------------
16// Default layer — creates an in-memory executor
17// ---------------------------------------------------------------------------
18
19export const ExecutorServiceLive = Layer.effect(
20 ExecutorService,
21 createExecutor(makeTestConfig()),
22);
apps/server/tsconfig.json+22−0
@@ -0,0 +1,22 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "ESNext",
5 "moduleResolution": "bundler",
6 "strict": true,
7 "esModuleInterop": true,
8 "skipLibCheck": true,
9 "outDir": "dist",
10 "rootDir": "src",
11 "declaration": true,
12 "declarationMap": true,
13 "sourceMap": true,
14 "jsx": "react-jsx",
15 "plugins": [
16 {
17 "name": "@effect/language-service"
18 }
19 ]
20 },
21 "include": ["src"]
22}
apps/web/index.html+12−0
@@ -0,0 +1,12 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Executor</title>
7 </head>
8 <body>
9 <div id="root"></div>
10 <script type="module" src="/src/main.tsx"></script>
11 </body>
12</html>
apps/web/package.json+30−0
@@ -0,0 +1,30 @@
1{
2 "name": "@executor/web",
3 "version": "0.0.0",
4 "private": true,
5 "type": "module",
6 "dependencies": {
7 "@effect/platform": "catalog:",
8 "@effect-atom/atom": "^0.5.3",
9 "@effect-atom/atom-react": "^0.5.0",
10 "@executor/api": "workspace:*",
11 "@executor/sdk": "workspace:*",
12 "@executor/server": "workspace:*",
13 "effect": "catalog:",
14 "react": "^19.1.0",
15 "react-dom": "^19.1.0"
16 },
17 "devDependencies": {
18 "@types/react": "^19.1.0",
19 "@types/react-dom": "^19.1.0",
20 "@types/node": "catalog:",
21 "@vitejs/plugin-react": "^4.5.2",
22 "typescript": "^5.9.3",
23 "vite": "^6.3.5"
24 },
25 "scripts": {
26 "dev": "bunx --bun vite",
27 "build": "vite build",
28 "typecheck": "tsc --noEmit"
29 }
30}
apps/web/src/App.tsx+46−0
@@ -0,0 +1,46 @@
1import React, { Suspense } from "react";
2import { RegistryProvider, useAtomValue, Result } from "@effect-atom/atom-react";
3
4import { ExecutorClient } from "./api/client";
5import { toolsAtom } from "./api/atoms";
6
7function ToolList() {
8 const tools = useAtomValue(toolsAtom());
9
10 if (tools._tag === "Initial" || tools.waiting) {
11 return <p>Loading tools…</p>;
12 }
13
14 if (tools._tag === "Failure") {
15 return <p style={{ color: "red" }}>Failed to load tools</p>;
16 }
17
18 return (
19 <div>
20 <h2>Tools ({tools.value.length})</h2>
21 {tools.value.length === 0 ? (
22 <p style={{ color: "#888" }}>No tools registered yet.</p>
23 ) : (
24 <ul>
25 {tools.value.map((t) => (
26 <li key={t.id}>
27 <strong>{t.name}</strong>
28 {t.description && <span> — {t.description}</span>}
29 </li>
30 ))}
31 </ul>
32 )}
33 </div>
34 );
35}
36
37export function App() {
38 return (
39 <RegistryProvider>
40 <div style={{ fontFamily: "system-ui", padding: "2rem" }}>
41 <h1>Executor</h1>
42 <ToolList />
43 </div>
44 </RegistryProvider>
45 );
46}
apps/web/src/api/atoms.ts+40−0
@@ -0,0 +1,40 @@
1import { ScopeId, ToolId, SecretId } from "@executor/sdk";
2import { ExecutorClient } from "./client";
3
4// ---------------------------------------------------------------------------
5// Query atoms — typed, cached, reactive
6// ---------------------------------------------------------------------------
7
8export const toolsAtom = (scopeId: ScopeId = ScopeId.make("default")) =>
9 ExecutorClient.query("tools", "list", {
10 path: { scopeId },
11 timeToLive: "30 seconds",
12 });
13
14export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>
15 ExecutorClient.query("tools", "schema", {
16 path: { scopeId, toolId },
17 timeToLive: "1 minute",
18 });
19
20export const secretsAtom = (scopeId: ScopeId = ScopeId.make("default")) =>
21 ExecutorClient.query("secrets", "list", {
22 path: { scopeId },
23 timeToLive: "30 seconds",
24 });
25
26export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) =>
27 ExecutorClient.query("secrets", "status", {
28 path: { scopeId, secretId },
29 timeToLive: "15 seconds",
30 });
31
32// ---------------------------------------------------------------------------
33// Mutation atoms — fire-and-forget style
34// ---------------------------------------------------------------------------
35
36export const invokeTool = ExecutorClient.mutation("tools", "invoke");
37
38export const setSecret = ExecutorClient.mutation("secrets", "set");
39
40export const removeSecret = ExecutorClient.mutation("secrets", "remove");
apps/web/src/api/client.ts+15−0
@@ -0,0 +1,15 @@
1import { AtomHttpApi } from "@effect-atom/atom-react";
2import { FetchHttpClient } from "@effect/platform";
3import { ExecutorApi } from "@executor/api";
4
5// ---------------------------------------------------------------------------
6// Typed HTTP API client — uses effect-atom's AtomHttpApi.Tag
7// ---------------------------------------------------------------------------
8
9class ExecutorClient extends AtomHttpApi.Tag<ExecutorClient>()("ExecutorClient", {
10 api: ExecutorApi,
11 httpClient: FetchHttpClient.layer,
12 baseUrl: "/",
13}) {}
14
15export { ExecutorClient };
apps/web/src/api/index.ts+10−0
@@ -0,0 +1,10 @@
1export { ExecutorClient } from "./client";
2export {
3 toolsAtom,
4 toolSchemaAtom,
5 secretsAtom,
6 secretStatusAtom,
7 invokeTool,
8 setSecret,
9 removeSecret,
10} from "./atoms";
apps/web/src/main.tsx+6−0
@@ -0,0 +1,6 @@
1import React from "react";
2import { createRoot } from "react-dom/client";
3import { App } from "./App";
4
5const root = createRoot(document.getElementById("root")!);
6root.render(<App />);
apps/web/tsconfig.json+19−0
@@ -0,0 +1,19 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "ESNext",
5 "moduleResolution": "bundler",
6 "strict": true,
7 "esModuleInterop": true,
8 "skipLibCheck": true,
9 "outDir": "dist",
10 "rootDir": ".",
11 "jsx": "react-jsx",
12 "plugins": [
13 {
14 "name": "@effect/language-service"
15 }
16 ]
17 },
18 "include": ["src", "vite.config.ts"]
19}
apps/web/vite.config.ts+67−0
@@ -0,0 +1,67 @@
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3import { createApiHandler } from "@executor/server";
4
5const api = createApiHandler();
6
7export default defineConfig({
8 plugins: [
9 react(),
10 {
11 name: "executor-api",
12 configureServer(server) {
13 // Forward /v1/* and /docs/* to the Effect API handler
14 server.middlewares.use(async (req, res, next) => {
15 const url = req.url ?? "/";
16 if (
17 url.startsWith("/v1/") ||
18 url.startsWith("/docs") ||
19 url === "/openapi.json"
20 ) {
21 const headers = new Headers();
22 for (const [key, value] of Object.entries(req.headers)) {
23 if (value === undefined) continue;
24 if (Array.isArray(value)) {
25 for (const v of value) headers.append(key, v);
26 } else {
27 headers.set(key, value);
28 }
29 }
30
31 const host = req.headers.host ?? "localhost";
32 const request = new Request(`http://${host}${url}`, {
33 method: req.method,
34 headers,
35 body:
36 req.method !== "GET" && req.method !== "HEAD"
37 ? (await new Promise<Uint8Array>((resolve) => {
38 const chunks: Uint8Array[] = [];
39 req.on("data", (c: Uint8Array) => chunks.push(c));
40 req.on("end", () => {
41 const total = chunks.reduce((n, c) => n + c.length, 0);
42 const buf = new Uint8Array(total);
43 let offset = 0;
44 for (const c of chunks) { buf.set(c, offset); offset += c.length; }
45 resolve(buf);
46 });
47 }) as unknown as BodyInit)
48 : undefined,
49 });
50
51 const response = await api.handler(request);
52
53 res.statusCode = response.status;
54 response.headers.forEach((value, key) => {
55 res.setHeader(key, value);
56 });
57
58 const body = await response.arrayBuffer();
59 res.end(Buffer.from(body));
60 return;
61 }
62 next();
63 });
64 },
65 },
66 ],
67});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t