8d72e1d27e
ci / rust (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite), extracted as one Effect-v4 package: - runtime: definePluginKit — async-main boundary hiding a ManagedRuntime (two-effect-instances discipline), signal-driven interruption with a bounded shutdown grace - config: Schema-driven raw round-trip (defaults only in the Schema via withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape), atomic writes, world-writable refusal, changes stream - cache-store: disposable derived state, corrupt→empty, write-through - reconcile: kit-owned provider wire schemas + typed client over the skew-safe untyped pf.request seam - sync-engine: generic poll/watch/debounce/single-flight-coalesce/ fingerprint-skip engine with a status PubSub (the SSE feed) - ui-server + sse: effect/unstable/httpapi behind servePluginUi with core-only env layers (validated by the phase-0 spikes); raw SSE route (httpapi has no event-stream media type) - cli: minimal command dispatcher reusing the plugin layer graph (deliberately not effect/unstable/cli — it needs platform packages) - react subpath: plugin router (path→hash→fallback deep-link restore + pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase - theme.css: the console's violet identity packaged for plugin UIs 18 bun tests incl. the two phase-0 spikes; publish workflow mirrors sdk-publish (tag plugin-kit-v*; 0.1.0 published manually). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 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 { 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. 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();
|
|
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();
|
|
}
|
|
});
|
|
});
|