Skip to content

Commit 001d1be

Browse files
committed
Make the API the single source: Effect HttpApi + OpenAPI
Define endpoints once as an Effect HttpApi (worker/api.ts); the typed server and /openapi.json both derive from it. detect is now an HttpApi endpoint (GET /api/<domain>/detect) backed by the detect() engine. Publish /.well-known/api-catalog pointing at our own OpenAPI + /mcp — dogfooding the discovery format the catalog indexes. Edge-cached. MCP + site untouched.
1 parent e2c8198 commit 001d1be

2 files changed

Lines changed: 86 additions & 32 deletions

File tree

worker/api.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* The API — the single source of truth.
3+
*
4+
* Endpoints are defined once as an Effect `HttpApi`; the typed server, the
5+
* OpenAPI document (`/openapi.json`), and downstream artifacts (MCP, CLI) all
6+
* derive from this. Runs as a pure web fetch handler on Cloudflare Workers.
7+
*/
8+
import { Effect, FileSystem, Layer, Path, Schema } from "effect";
9+
import { Etag, HttpPlatform } from "effect/unstable/http";
10+
import * as HttpRouter from "effect/unstable/http/HttpRouter";
11+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
12+
import { detect } from "../src/lib/detect.ts";
13+
14+
// Response shape (top-level typed; per-format detail kept loose for now).
15+
const DetectionResult = Schema.Struct({
16+
domain: Schema.String,
17+
found: Schema.Array(Schema.String),
18+
apiCatalog: Schema.optional(Schema.Unknown),
19+
apiSchema: Schema.optional(Schema.Unknown),
20+
mcp: Schema.Array(Schema.Unknown),
21+
agentCard: Schema.optional(Schema.Unknown),
22+
agentSkills: Schema.optional(Schema.Unknown),
23+
llmsTxt: Schema.Boolean,
24+
errors: Schema.Array(Schema.String),
25+
});
26+
27+
const Detect = HttpApiEndpoint.get("detect", "/api/:domain/detect", {
28+
params: Schema.Struct({ domain: Schema.String }),
29+
success: DetectionResult,
30+
});
31+
32+
export const Api = HttpApi.make("integrations.sh").add(
33+
HttpApiGroup.make("detect").add(Detect),
34+
);
35+
36+
const DetectGroup = HttpApiBuilder.group(Api, "detect", (handlers) =>
37+
handlers.handle("detect", (req: { readonly params: { readonly domain: string } }) =>
38+
Effect.promise(() => detect(req.params.domain.trim().toLowerCase()) as Promise<typeof DetectionResult.Type>),
39+
),
40+
);
41+
42+
const Platform = Layer.mergeAll(Path.layer, Etag.layerWeak, HttpPlatform.layer).pipe(
43+
Layer.provideMerge(FileSystem.layerNoop({})),
44+
);
45+
46+
const ApiLive = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
47+
Layer.provide(DetectGroup),
48+
Layer.provide(Platform),
49+
);
50+
51+
const built = HttpRouter.toWebHandler(ApiLive as never);
52+
export const apiHandler = built.handler as (req: Request) => Promise<Response>;

worker/index.ts

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { detect } from "../src/lib/detect.ts";
1+
import { apiHandler } from "./api.ts";
22

33
export { McpDurableObject } from "./mcp-do.ts";
44

@@ -22,13 +22,6 @@ const json = (body: unknown, status = 200, headers: Record<string, string> = {})
2222
headers: { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*", ...headers },
2323
});
2424

25-
// Normalize "https://Vercel.com/foo" / "vercel.com" -> "vercel.com"; reject non-domains.
26-
function cleanDomain(input: string | null): string | null {
27-
if (!input) return null;
28-
const d = input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
29-
return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d) ? d : null;
30-
}
31-
3225
export default {
3326
async fetch(
3427
request: Request,
@@ -37,49 +30,58 @@ export default {
3730
): Promise<Response> {
3831
const url = new URL(request.url);
3932

40-
// MCP server (write-once tool registry) — point Claude/Cursor at /mcp.
41-
// Routed through a single Durable Object so the session map persists.
33+
// MCP server — point Claude/Cursor at /mcp. Routed through a single Durable
34+
// Object so the session map persists across stateless Worker requests.
4235
if (url.pathname === "/mcp") {
43-
const stub = env.MCP.get(env.MCP.idFromName("mcp"));
44-
return stub.fetch(request);
36+
return env.MCP.get(env.MCP.idFromName("mcp")).fetch(request);
37+
}
38+
39+
// Self-describe via the same discovery format the catalog indexes: point at
40+
// our own OpenAPI + MCP endpoint.
41+
if (url.pathname === "/.well-known/api-catalog") {
42+
return json(
43+
{
44+
linkset: [{
45+
anchor: "https://integrations.sh",
46+
"service-desc": [{ href: "https://integrations.sh/openapi.json", type: "application/openapi+json" }],
47+
"service-doc": [{ href: "https://integrations.sh" }],
48+
item: [{ href: "https://integrations.sh/mcp", type: "application/json" }],
49+
}],
50+
},
51+
200,
52+
{ "cache-control": "public, max-age=86400" },
53+
);
4554
}
4655

47-
// Detection endpoint: run the full agent-readiness battery for a domain.
48-
// GET /api/<domain>/detect -> structured DetectionResult (cached 1h).
49-
const detectMatch = url.pathname.match(/^\/api\/([^/]+)\/detect\/?$/);
50-
if (detectMatch) {
51-
const domain = cleanDomain(decodeURIComponent(detectMatch[1]));
52-
if (!domain) return json({ error: "invalid domain — GET /api/<domain>/detect" }, 400);
53-
const cacheKey = new Request(`https://integrations.sh/api/${domain}/detect`);
56+
// The API + its OpenAPI doc — single source of truth (worker/api.ts).
57+
if (url.pathname === "/openapi.json" || url.pathname.startsWith("/api/")) {
5458
const cache = (caches as unknown as { default: Cache }).default;
55-
const cached = await cache.match(cacheKey);
59+
const cached = await cache.match(request);
5660
if (cached) return cached;
57-
const result = await detect(domain);
58-
const res = json(result, 200, { "cache-control": "public, max-age=3600" });
59-
ctx.waitUntil(cache.put(cacheKey, res.clone()));
61+
const res = await apiHandler(request);
62+
if (request.method === "GET" && res.status === 200) {
63+
const out = new Response(res.clone().body, res);
64+
out.headers.set("cache-control", "public, max-age=3600");
65+
ctx.waitUntil(cache.put(request, out.clone()));
66+
return out;
67+
}
6068
return res;
6169
}
6270

71+
// Analytics: count executor-agent hits.
6372
const ip = request.headers.get("cf-connecting-ip") || "unknown";
6473
const country = request.headers.get("cf-ipcountry") || "unknown";
6574
const agent = request.headers.get("user-agent") || "unknown";
6675
if (agent.includes("executor")) {
6776
ctx.waitUntil(
6877
fetch("https://us.i.posthog.com/i/v0/e/", {
6978
method: "POST",
70-
headers: {
71-
"Content-Type": "application/json",
72-
},
79+
headers: { "Content-Type": "application/json" },
7380
body: JSON.stringify({
7481
api_key: env.POSTHOG_KEY,
7582
event: "hit",
7683
distinct_id: ip,
77-
properties: {
78-
$process_person_profile: false,
79-
user_agent: agent,
80-
country,
81-
path: url.pathname,
82-
},
84+
properties: { $process_person_profile: false, user_agent: agent, country, path: url.pathname },
8385
}),
8486
}),
8587
);

0 commit comments

Comments
 (0)