Files
punktfunk/plugin-kit/test/spike-httpapi.test.ts
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

160 lines
5.6 KiB
TypeScript

// 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 type { Punktfunk } from "@punktfunk/host";
import { servePluginUi } from "@punktfunk/host";
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";
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. FileSystem is
// provideMerge'd so both HttpPlatform.layer and HttpApiBuilder see it satisfied.
const env = Layer.provideMerge(
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
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();
// Not `reg?.body`: the optional chain undoes the assertion above — if `reg` were
// undefined the `.ui` access would throw a TypeError instead of failing this test
// readably. The `expect` is what guarantees it, so assert it to the type system too.
if (!reg) throw new Error("registration not found");
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();
}
});
});