feat(plugin-kit): scaffold @punktfunk/plugin-kit + phase-0 spikes

Two spikes validating the riskiest seams of the plugin-kit design before
implementation:

- HttpApi (effect/unstable/httpapi) served on Bun behind the SDK's
  servePluginUi using ONLY effect-core layers (Etag.layerWeak, Path.layer,
  HttpPlatform.layer + FileSystem.layerNoop) — no platform package. Auth,
  schema validation, and static fallthrough all verified end-to-end.
- Browser client prefix strategy for the console proxy: both
  transformClient+prependUrl AND baseUrl preserve the /plugin-ui/<id>
  path prefix. Nested Schema.withDecodingDefaultKey defaults confirmed
  (Effect-valued defaults; encodingStrategy "omit" restores raw shape
  on encode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:19:25 +02:00
parent de7b54d4e0
commit bb58fde369
8 changed files with 593 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
// 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<Response | undefined> => {
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();
}
});
});