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:
@@ -0,0 +1,143 @@
|
||||
// Spike 2 (plan Phase 0): prove the browser-side client strategy for the console proxy
|
||||
// prefix (`/plugin-ui/<id>/...`), headless via AtomRegistry (no React needed).
|
||||
//
|
||||
// The console serves the plugin SPA under a path prefix; the HttpApi contract uses
|
||||
// absolute endpoint paths ("/api/status"). This spike pins down how the derived client
|
||||
// must be configured so requests keep the prefix:
|
||||
// - transformClient + HttpClientRequest.prependUrl(prefix) → expected to work
|
||||
// - baseUrl with a path prefix → documented behavior
|
||||
// Also verifies the nested `withDecodingDefaultKey` config-defaults pattern the kit's
|
||||
// Config service relies on.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Effect, Layer, Schema } from "effect";
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http";
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
} from "effect/unstable/httpapi";
|
||||
import { AtomHttpApi, AtomRegistry } from "effect/unstable/reactivity";
|
||||
|
||||
const Pong = Schema.Struct({ ok: Schema.Boolean });
|
||||
|
||||
const api = HttpApi.make("spike").add(
|
||||
HttpApiGroup.make("spike").add(
|
||||
HttpApiEndpoint.get("ping", "/api/ping", { success: Pong }),
|
||||
),
|
||||
);
|
||||
|
||||
const captureClient = (captured: Array<string>) =>
|
||||
HttpClient.make((request) => {
|
||||
captured.push(request.url);
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(request, Response.json({ ok: true })),
|
||||
);
|
||||
});
|
||||
|
||||
const PREFIX = "http://plugin.local/plugin-ui/rom-manager";
|
||||
|
||||
describe("spike 2: client prefix through the console proxy", () => {
|
||||
test("transformClient + prependUrl keeps the path prefix", async () => {
|
||||
const captured: Array<string> = [];
|
||||
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
|
||||
api,
|
||||
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
||||
captureClient(captured),
|
||||
),
|
||||
transformClient: HttpClient.mapRequest(
|
||||
HttpClientRequest.prependUrl(PREFIX),
|
||||
),
|
||||
}) {}
|
||||
|
||||
const registry = AtomRegistry.make();
|
||||
const result = await Effect.runPromise(
|
||||
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]).toBe(`${PREFIX}/api/ping`);
|
||||
});
|
||||
|
||||
test("documents baseUrl behavior with a path-prefix base", async () => {
|
||||
const captured: Array<string> = [];
|
||||
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
|
||||
api,
|
||||
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
||||
captureClient(captured),
|
||||
),
|
||||
baseUrl: PREFIX,
|
||||
}) {}
|
||||
|
||||
const registry = AtomRegistry.make();
|
||||
await Effect.runPromise(
|
||||
AtomRegistry.getResult(registry, Api.query("spike", "ping", {})),
|
||||
);
|
||||
expect(captured).toHaveLength(1);
|
||||
// If this equals `${PREFIX}/api/ping`, baseUrl would also be fine; if the prefix is
|
||||
// dropped (URL-resolution semantics for absolute paths), transformClient is the way.
|
||||
// Either way the assertion records the actual behavior for the kit docs.
|
||||
console.log("baseUrl produced:", captured[0]);
|
||||
expect(captured[0]).toContain("/api/ping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spike 2b: nested withDecodingDefaultKey config defaults", () => {
|
||||
// withDecodingDefaultKey wraps the schema in optionalKey itself; the default is an
|
||||
// Effect producing the ENCODED value. `encodingStrategy: "omit"` makes encode drop
|
||||
// defaulted keys again — the raw-round-trip behavior the kit Config service wants.
|
||||
const SyncCfg = Schema.Struct({
|
||||
pollMinutes: Schema.Number.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
watch: Schema.Boolean.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const Cfg = Schema.Struct({
|
||||
roots: Schema.Array(Schema.String).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
sync: SyncCfg.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
test("empty raw file decodes to full defaults (nested)", () => {
|
||||
const decoded = Schema.decodeUnknownSync(Cfg)({});
|
||||
expect(decoded).toEqual({
|
||||
roots: [],
|
||||
sync: { pollMinutes: 15, watch: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("partially-authored raw keeps authored values, fills the rest", () => {
|
||||
const decoded = Schema.decodeUnknownSync(Cfg)({
|
||||
roots: ["/roms"],
|
||||
sync: { pollMinutes: 5 },
|
||||
});
|
||||
expect(decoded).toEqual({
|
||||
roots: ["/roms"],
|
||||
sync: { pollMinutes: 5, watch: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("unknown keys in the raw file are tolerated (legacy ui/devEntry)", () => {
|
||||
const decoded = Schema.decodeUnknownSync(Cfg)({
|
||||
ui: { port: 5885 },
|
||||
devEntry: true,
|
||||
} as unknown);
|
||||
expect(decoded.roots).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user