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 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Effect, Option } from "effect";
3+import type { SecretId, ScopeId } from "@executor/sdk";
4+
5+import { ExecutorApi } from "@executor/api";
6+import { ExecutorService } from "../services/executor";
7+
8+const 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+
24+export 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 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Effect } from "effect";
3+
4+import { ExecutorApi } from "@executor/api";
5+import { ExecutorService } from "../services/executor";
6+
7+export 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 @@
1+export { createApiHandler, type ApiHandler } from "./main";
2+export { ExecutorServiceLive } from "./services/executor";
apps/server/src/main.ts+40−0
@@ -0,0 +1,40 @@
1+import {
2+ HttpApiBuilder,
3+ HttpApiSwagger,
4+ HttpMiddleware,
5+ HttpServer,
6+} from "@effect/platform";
7+import { Layer } from "effect";
8+
9+import { ExecutorApi } from "@executor/api";
10+import { ToolsHandlers } from "./handlers/tools";
11+import { SecretsHandlers } from "./handlers/secrets";
12+import { ExecutorServiceLive } from "./services/executor";
13+
14+// ---------------------------------------------------------------------------
15+// API layer — wire handlers
16+// ---------------------------------------------------------------------------
17+
18+const 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+
27+export 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+
38+export type ApiHandler = ReturnType<typeof createApiHandler>;
39+
40+export { ExecutorServiceLive } from "./services/executor";
apps/server/src/services/executor.ts+22−0
@@ -0,0 +1,22 @@
1+import { Context, Effect, Layer } from "effect";
2+import { createExecutor, makeTestConfig } from "@executor/sdk";
3+
4+import type { Executor } from "@executor/sdk";
5+
6+// ---------------------------------------------------------------------------
7+// Service tag — provides the executor instance to HTTP handlers
8+// ---------------------------------------------------------------------------
9+
10+export class ExecutorService extends Context.Tag("ExecutorService")<
11+ ExecutorService,
12+ Executor
13+>() {}
14+
15+// ---------------------------------------------------------------------------
16+// Default layer — creates an in-memory executor
17+// ---------------------------------------------------------------------------
18+
19+export 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 @@
1+import React, { Suspense } from "react";
2+import { RegistryProvider, useAtomValue, Result } from "@effect-atom/atom-react";
3+
4+import { ExecutorClient } from "./api/client";
5+import { toolsAtom } from "./api/atoms";
6+
7+function 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+
37+export 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 @@
1+import { ScopeId, ToolId, SecretId } from "@executor/sdk";
2+import { ExecutorClient } from "./client";
3+
4+// ---------------------------------------------------------------------------
5+// Query atoms — typed, cached, reactive
6+// ---------------------------------------------------------------------------
7+
8+export const toolsAtom = (scopeId: ScopeId = ScopeId.make("default")) =>
9+ ExecutorClient.query("tools", "list", {
10+ path: { scopeId },
11+ timeToLive: "30 seconds",
12+ });
13+
14+export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>
15+ ExecutorClient.query("tools", "schema", {
16+ path: { scopeId, toolId },
17+ timeToLive: "1 minute",
18+ });
19+
20+export const secretsAtom = (scopeId: ScopeId = ScopeId.make("default")) =>
21+ ExecutorClient.query("secrets", "list", {
22+ path: { scopeId },
23+ timeToLive: "30 seconds",
24+ });
25+
26+export 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+
36+export const invokeTool = ExecutorClient.mutation("tools", "invoke");
37+
38+export const setSecret = ExecutorClient.mutation("secrets", "set");
39+
40+export const removeSecret = ExecutorClient.mutation("secrets", "remove");
apps/web/src/api/client.ts+15−0
@@ -0,0 +1,15 @@
1+import { AtomHttpApi } from "@effect-atom/atom-react";
2+import { FetchHttpClient } from "@effect/platform";
3+import { ExecutorApi } from "@executor/api";
4+
5+// ---------------------------------------------------------------------------
6+// Typed HTTP API client — uses effect-atom's AtomHttpApi.Tag
7+// ---------------------------------------------------------------------------
8+
9+class ExecutorClient extends AtomHttpApi.Tag<ExecutorClient>()("ExecutorClient", {
10+ api: ExecutorApi,
11+ httpClient: FetchHttpClient.layer,
12+ baseUrl: "/",
13+}) {}
14+
15+export { ExecutorClient };
apps/web/src/api/index.ts+10−0
@@ -0,0 +1,10 @@
1+export { ExecutorClient } from "./client";
2+export {
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 @@
1+import React from "react";
2+import { createRoot } from "react-dom/client";
3+import { App } from "./App";
4+
5+const root = createRoot(document.getElementById("root")!);
6+root.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 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import { createApiHandler } from "@executor/server";
4+
5+const api = createApiHandler();
6+
7+export 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