Files
punktfunk/sdk/src/effect.ts
T
enricobuehler 87114ab186
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m21s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m42s
docker / deploy-docs (push) Successful in 26s
feat(sdk): @punktfunk/host — the Effect TypeScript SDK (M3)
New top-level sdk/ package (RFC §7): a typed management-API client plus
the lifecycle event stream, built on Effect, two surfaces over one core:

- @punktfunk/host — the Promise facade front door: connect() resolves
  URL/token/TLS pin from the host's own files (zero config on the box),
  fails fast on bad credentials, pf.events.on() with typed callbacks
  (exact kinds, domain.* prefixes, "*", "dropped", "unknown"),
  pf.request() for the REST surface. Effect never required.
- @punktfunk/host/effect — the PunktfunkHost service + PunktfunkHostLive
  layer, Stream-based events()/eventsRaw(), typed errors
  (AuthError | ApiError | TransportError | VersionSkew — a 2xx that
  fails its schema is a typed skew, not undefined later), and every
  wire shape as an effect/Schema: REST generated via orval
  client:'effect' from api/openapi.json (S3 spike: works well; the
  text/event-stream payload is out of its reach), events hand-mirrored
  from the host's snapshot-tested wire format as a kind-discriminated
  union.

One reconnecting SSE core under both surfaces: spec-shaped parser,
exponential+jittered backoff (capped, resets after a healthy
connection), Last-Event-ID resume, 401 terminal. Default is LIVE tail
only — a fresh notify script must not re-fire on the host's replayed
ring (since: 0 opts into full replay).

TLS: the pin trusts exactly the host's self-signed identity cert
(chain-verified; hostname check waived — the cert is deliberately
CN-only for fingerprint pinning). Bun via fetch tls, Node via an undici
dispatcher (optionalDependency).

definePlugin() accepts both main shapes (async fn | Effect requiring
PunktfunkHost). Examples in both styles; README carries the compat
contract + systemd/Task Scheduler templates.

11 bun tests green (wire decode against the Rust snapshot strings,
SSE parser/reconnect/Last-Event-ID/401, both surfaces vs a mock host).
Live-verified against a real host on Bun AND Node through the pinned
loopback hop: connect → REST mutate → live event received → resume
cursor advanced; a wrong CA is rejected. npm publish + CI wiring
deferred (npm org = RFC open question 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:14:39 +02:00

71 lines
2.5 KiB
TypeScript

// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service
// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root
// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs.
//
// import { Effect, Stream } from "effect";
// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect";
//
// const program = Effect.gen(function* () {
// yield* events().pipe(
// Stream.filter((e) => e.kind === "stream.started"),
// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)),
// );
// });
// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
import { Effect, type Schema as S, Stream } from "effect";
import {
EventStreamError,
PunktfunkHost,
type RequestError,
type VersionSkew,
} from "./client.js";
import type { EventStreamOptions, SseFrame } from "./sse.js";
import type { HostEvent } from "./wire.js";
export {
ApiError,
AuthError,
EventStreamError,
layer,
makeService,
PunktfunkHost,
type PunktfunkHostService,
PunktfunkHostLive,
type RequestError,
SseAuthError,
TransportError,
VersionSkew,
} from "./client.js";
export { type ConnectOptions, configDir, resolveConfig } from "./config.js";
export type { EventStreamOptions, SseFrame } from "./sse.js";
export * from "./wire.js";
/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */
export * as api from "./gen/schemas.js";
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
export const events = (
opts?: EventStreamOptions,
): Stream.Stream<HostEvent, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts)));
/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */
export const eventsRaw = (
opts?: EventStreamOptions,
): Stream.Stream<SseFrame, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts)));
/** One management-API request under `/api/v1` on the ambient service. */
export const request = (
method: string,
path: string,
body?: unknown,
): Effect.Effect<unknown, RequestError, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body));
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
export const get = <A, I>(
path: string,
schema: S.Schema<A, I>,
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));