// Spike 1 (plan Phase 0): prove that an `effect/unstable/httpapi` HttpApi can serve as the // plugin-local API behind the SDK's `servePluginUi` on Bun, using ONLY effect-core layers // (no @effect/platform-node / platform-bun) — the riskiest seam of the plugin-kit design. // // Validates: // 1. HttpApi + HttpApiBuilder.group + HttpRouter.toWebHandler answer plain fetch Requests. // 2. The handler slots into servePluginUi's `fetch` contract: /api/* handled, everything // else falls through (returns undefined) to the static/404 path. // 3. The real servePluginUi server (loopback, per-boot bearer secret, __health) proxies // into the HttpApi handler end-to-end. import { describe, expect, test } from "bun:test"; import { Effect, Layer, Schema } from "effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http"; import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, } from "effect/unstable/httpapi"; import { servePluginUi } from "@punktfunk/host"; import type { Punktfunk } from "@punktfunk/host"; const Pong = Schema.Struct({ ok: Schema.Boolean, source: Schema.String }); const EchoIn = Schema.Struct({ msg: Schema.String }); const EchoOut = Schema.Struct({ echoed: Schema.String }); const api = HttpApi.make("spike").add( HttpApiGroup.make("spike") .add(HttpApiEndpoint.get("ping", "/api/ping", { success: Pong })) .add( HttpApiEndpoint.post("echo", "/api/echo", { payload: EchoIn, success: EchoOut, }), ), ); const groupLive = HttpApiBuilder.group(api, "spike", (handlers) => handlers .handle("ping", () => Effect.succeed({ ok: true, source: "httpapi" })) .handle("echo", ({ payload }) => Effect.succeed({ echoed: payload.msg })), ); // Core-only environment for HttpApiBuilder: no platform package needed. const env = Layer.mergeAll( Etag.layerWeak, Path.layer, HttpPlatform.layer.pipe(Layer.provide(FileSystem.layerNoop({}))), ); const appLayer = HttpApiBuilder.layer(api).pipe( Layer.provide(groupLive), Layer.provide(env), ); describe("spike 1: HttpApi via toWebHandler on Bun", () => { test("handles fetch-shaped requests directly", async () => { const { handler, dispose } = HttpRouter.toWebHandler(appLayer); try { const ping = await handler(new Request("http://127.0.0.1/api/ping")); expect(ping.status).toBe(200); expect(await ping.json()).toEqual({ ok: true, source: "httpapi" }); const echo = await handler( new Request("http://127.0.0.1/api/echo", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ msg: "hello" }), }), ); expect(echo.status).toBe(200); expect(await echo.json()).toEqual({ echoed: "hello" }); // Schema validation is live: bad payload is rejected, not 500. const bad = await handler( new Request("http://127.0.0.1/api/echo", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ nope: 1 }), }), ); expect(bad.status).toBeGreaterThanOrEqual(400); expect(bad.status).toBeLessThan(500); } finally { await dispose(); } }); test("end-to-end behind servePluginUi (loopback + bearer secret)", async () => { const { handler, dispose } = HttpRouter.toWebHandler(appLayer); const registrations: Array<{ method: string; path: string; body: unknown }> = []; // servePluginUi only touches pf.request — a recording stub is a faithful host. const pf = { request: async (method: string, path: string, body?: unknown) => { registrations.push({ method, path, body }); return undefined; }, } as unknown as Punktfunk; const kitFetch = async (req: Request): Promise => { const url = new URL(req.url); if (!url.pathname.startsWith("/api/")) return undefined; // static/404 fallthrough return handler(req); }; const ui = await servePluginUi(pf, { id: "spike", title: "Spike", fetch: kitFetch, }); try { const reg = registrations.find( (r) => r.method === "PUT" && r.path === "/plugins/spike", ); expect(reg).toBeDefined(); const secret = (reg?.body as { ui: { secret: string } }).ui.secret; expect(secret.length).toBeGreaterThanOrEqual(16); const auth = { authorization: `Bearer ${secret}` }; // Health endpoint is served by servePluginUi itself. const health = await fetch( `http://127.0.0.1:${ui.port}/__health`, { headers: auth }, ); expect(health.status).toBe(200); // HttpApi endpoint through the real server. const ping = await fetch(`http://127.0.0.1:${ui.port}/api/ping`, { headers: auth, }); expect(ping.status).toBe(200); expect(await ping.json()).toEqual({ ok: true, source: "httpapi" }); // Wrong secret is rejected before reaching the handler. const denied = await fetch(`http://127.0.0.1:${ui.port}/api/ping`, { headers: { authorization: "Bearer nope-nope-nope-nope" }, }); expect(denied.status).toBe(401); // Non-/api path falls through past our fetch (no staticDir here → 404). const missing = await fetch(`http://127.0.0.1:${ui.port}/somewhere`, { headers: auth, }); expect(missing.status).toBe(404); } finally { await ui.close(); await dispose(); } }); });