259 lines · 9.8 KB · TypeScript
RawDownload
import { Effect } from "effect"; import type { AppsRuntime } from "../plugin/runtime";import type { Bindings } from "../plugin/bindings"; // ---------------------------------------------------------------------------// Apps HTTP surface — a plain web handler (Request -> Response) the self-host// app mounts as an extension route under `/api/apps/*`. Covers://   POST /api/apps/:scope/publish         { files }        -> descriptor//   GET  /api/apps/:scope/descriptor                       -> descriptor//   POST /api/apps/:scope/tools/:tool     { args, bindings } -> result//   POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run//   POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run//   GET  /api/apps/:scope/workflows/runs/:runId            -> run + steps//   GET  /api/apps/:scope/workflows/runs                   -> runs//   GET  /api/apps/:scope/ui/:name                         -> compiled bundle (JS)//   GET  /api/apps/:scope/live (SSE)                       -> invalidations//// It's deliberately transport-thin: all logic is in AppsRuntime.// --------------------------------------------------------------------------- export interface AppsHttpDeps {  readonly runtime: AppsRuntime;  /** Mount prefix (default "/api/apps"). */  readonly prefix?: string;  /**   * Authenticate an inbound request. Returns `true` when the caller is   * authorized and `false` otherwise; the handler answers `false` with a 401.   *   * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates   * per-scope state and reaches real integrations, so it MUST be behind the same   * credential the rest of `/api` requires. This seam lets the host thread its   * own identity check (self-host's Better Auth session/bearer/api-key) in   * without this package importing the host's auth stack. When omitted (tests   * that drive the runtime directly) every request is allowed.   */  readonly authenticate?: (request: Request) => Promise<boolean>;} const json = (body: unknown, status = 200): Response =>  new Response(JSON.stringify(body), {    status,    headers: { "content-type": "application/json" },  }); const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never); const unknownMessage = (cause: unknown): string => {  // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: HTTP adapter preserves existing error response text  return cause instanceof Error ? cause.message : String(cause);}; export const makeAppsHttpRoutes = (  deps: AppsHttpDeps,): { readonly path: string; readonly handler: (request: Request) => Promise<Response> } => {  const prefix = deps.prefix ?? "/api/apps";  const runtime = deps.runtime;   const handler = async (request: Request): Promise<Response> => {    const url = new URL(request.url);    if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 });     // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is    // behind the host's identity check. An unauthenticated caller gets a 401    // BEFORE any route logic runs — including the SSE stream.    if (deps.authenticate) {      const ok = await deps.authenticate(request).then(        (value) => value,        () => false,      );      if (!ok) {        return new Response(JSON.stringify({ error: "Unauthorized" }), {          status: 401,          headers: { "content-type": "application/json" },        });      }    }     const rest = url.pathname.slice(prefix.length).replace(/^\//, "");    const parts = rest.split("/").filter(Boolean);    // parts[0] = scope    const scope = parts[0];    if (!scope) return json({ error: "scope required" }, 400);     // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: HTTP handler converts route/runtime failures into JSON 400 responses    try {      // POST :scope/publish      if (parts[1] === "publish" && request.method === "POST") {        const body = (await request.json()) as { files: Record<string, string>; message?: string };        const files = new Map(Object.entries(body.files ?? {}));        const out = await run(runtime.publish({ scope, files, message: body.message }));        return json({ snapshotId: out.snapshotId, descriptor: out.descriptor });      }       // GET :scope/descriptor      if (parts[1] === "descriptor" && request.method === "GET") {        const descriptor = await run(runtime.getDescriptor(scope));        return json({ descriptor });      }       // POST :scope/tools/:tool      if (parts[1] === "tools" && parts[2] && request.method === "POST") {        const body = (await request.json()) as { args?: unknown; bindings?: Bindings };        const result = await run(          runtime.invokeTool({            scope,            tool: parts[2],            args: body.args ?? {},            bindings: body.bindings ?? {},          }),        );        return json({ result });      }       // POST :scope/workflows/:wf/start      if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") {        const body = (await request.json()) as {          input?: unknown;          bindings?: Bindings;          runId?: string;        };        const runView = await run(          runtime.startWorkflow({            scope,            workflow: parts[2],            input: body.input,            bindings: body.bindings,            runId: body.runId,          }),        );        return json({ run: runView });      }       // POST :scope/workflows/runs/:runId/signal      if (        parts[1] === "workflows" &&        parts[2] === "runs" &&        parts[3] &&        parts[4] === "signal" &&        request.method === "POST"      ) {        const body = (await request.json()) as { event: string; payload?: unknown };        const runView = await run(          runtime.signalWorkflow({            scope,            runId: parts[3],            event: body.event,            payload: body.payload,          }),        );        return json({ run: runView });      }       // GET :scope/workflows/runs/:runId      if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") {        const runView = await run(runtime.getRun(parts[3]));        const steps = await run(runtime.listSteps(parts[3]));        return json({ run: runView, steps });      }       // GET :scope/workflows/runs      if (        parts[1] === "workflows" &&        parts[2] === "runs" &&        !parts[3] &&        request.method === "GET"      ) {        const runs = await run(runtime.listRuns(scope));        return json({ runs });      }       // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML      // document when `?document=html` (Fix 10: the fallback a non-UI MCP client      // opens in a browser). Both are behind the same auth gate as the rest of      // the surface.      if (parts[1] === "ui" && parts[2] && request.method === "GET") {        if (url.searchParams.get("document") === "html") {          const doc = await run(runtime.getUiDocument(scope, parts[2]));          if (!doc) return new Response("ui not found", { status: 404 });          return new Response(doc.html, {            status: 200,            headers: {              "content-type": "text/html; charset=utf-8",              "x-ui-title": doc.title ?? "",              "x-ui-max-height": String(doc.maxHeight ?? ""),            },          });        }        const bundle = await run(runtime.getUiBundle(scope, parts[2]));        if (!bundle) return new Response("ui not found", { status: 404 });        return new Response(bundle.code, {          status: 200,          headers: {            "content-type": "application/javascript",            "x-ui-title": bundle.title ?? "",            "x-ui-max-height": String(bundle.maxHeight ?? ""),          },        });      }       // GET :scope/live -> SSE invalidations      if (parts[1] === "live" && request.method === "GET") {        return sseResponse(scope, runtime);      }       return new Response("not found", { status: 404 });    } catch (cause) {      return json({ error: unknownMessage(cause) }, 400);    }  };   return { path: `${prefix}/*`, handler };}; // An SSE stream of `{table, version}` invalidations for a scope. Each scope-db// write bumps a counter and publishes here; a `: keepalive` comment holds the// connection open.const sseResponse = (scope: string, runtime: AppsRuntime): Response => {  let unsubscribe: (() => void) | undefined;  let keepalive: ReturnType<typeof setInterval> | undefined;  const stream = new ReadableStream<Uint8Array>({    start(controller) {      const enc = new TextEncoder();      controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`));      unsubscribe = runtime.subscribeLive(scope, (event) => {        // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: ReadableStream enqueue throws after the client closes        try {          controller.enqueue(            enc.encode(              `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`,            ),          );        } catch {          /* controller closed */        }      });      keepalive = setInterval(() => {        // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: keepalive enqueue is best-effort after disconnect        try {          controller.enqueue(enc.encode(`: keepalive\n\n`));        } catch {          /* closed */        }      }, 15_000);    },    cancel() {      unsubscribe?.();      if (keepalive) clearInterval(keepalive);    },  });  return new Response(stream, {    status: 200,    headers: {      "content-type": "text/event-stream",      "cache-control": "no-cache",      connection: "keep-alive",    },  });};