@postel/effect

A first-class Effect-TS layer over Postel — Layer-managed worker lifecycle, a typed PostelError channel, and Effect-wrapped send/verify/messages/replay.

View as Markdown

@postel/effect wraps the Postel core API for Effect-TS: no callbacks bolted onto a Promise API, and no manual lifecycle management. Acquiring the Layer starts the outbound worker pool; releasing its Scope stops it gracefully.

Install

npm install @postel/core @postel/effect effect

effect is a peer dependency — bring your own version (^3.10 or later).

PostelLive and PostelTag

import { PostelLive, PostelTag } from "@postel/effect";
import { InMemoryStorage, Secret } from "@postel/core";
import { Effect } from "effect";

const config = {
  outbound: { storage: InMemoryStorage() },
  inbound: { github: { verify: Secret(process.env.GITHUB_WEBHOOK_SECRET!) } },
} as const;

const Postel = PostelTag<typeof config>();

const program = Effect.gen(function* () {
  const postel = yield* Postel;
  const { id } = yield* postel.outbound.send({ type: "order.created", data: { id: "o_1" } });
  return id;
});

await Effect.runPromise(program.pipe(Effect.provide(PostelLive(config))));

PostelLive(config) builds a scoped Layer<PostelEffectApi<typeof config>>: acquiring it constructs the Postel instance and calls start(); when the layer's Scope closes (the effect it was provided to finishes, in the example above), stop() runs. Nothing in the program above touches the Promise-based postel.start() / postel.stop() directly.

PostelTag<C>() identifies the service in Context. Pass the same config type at both PostelTag<typeof config>() and PostelLive(config) so the two sides agree on which outbound/inbound slots are configured — exactly like PostelInstance<C> in @postel/core.

Effect-wrapped surfaces

postel.outbound.send, postel.outbound.replay, postel.outbound.messages.{get,attempts,list}, and each configured source's postel.inbound.<source>.verify return Effects instead of Promises:

const program = Effect.gen(function* () {
  const postel = yield* Postel;
  const result = yield* postel.inbound.github.verify(rawBody, headers);
  const page = yield* postel.outbound.messages.list({ limit: 20 });
  yield* postel.outbound.replay({ messageId: result.event.type, freshWebhookId: true });
});

These compose with the rest of the Effect ecosystem with no bridging utilities — pipe(postel.outbound.send(...), Effect.flatMap(...)), Effect.retry, Effect.timeout, and so on all work as they would over any other Effect.

The typed error channel

PostelErrors (PostelError | ConfigurationError | NotImplementedError) is the failure type on every wrapped method. A SignatureInvalid thrown by the Promise-based @postel/core verify() fails the Effect with that same error instance — catchable via Effect.catchTag("SignatureInvalid", ...) or Effect.catchAll:

const verified = postel.inbound.github.verify(rawBody, headers).pipe(
  Effect.catchTag("SignatureInvalid", () => Effect.succeed(null)),
);

An error outside that set (a programmer mistake — e.g. the RangeError a non-positive limit raises) is not folded into PostelErrors; it surfaces as an Effect defect instead, since it isn't a recoverable webhook-protocol outcome.

Exported types

PostelEffectApi is the service shape behind PostelTag; its halves are exported separately — PostelEffectOutboundApi / PostelEffectInboundApi, and the conditional wrappers PostelEffectWithOutbound<C> / PostelEffectWithInbound<C> mirror core's WithOutbound/WithInbound so only the halves your config declares exist on the service type.

Scope

This is a TypeScript-port ergonomic: send / replay / messages / verify themselves behave identically to their @postel/core counterparts (see Outbound and Inbound) — @postel/effect only changes how you call them. Endpoint/tenant/key management and the raw lifecycle (health, metrics, on/off) stay on the underlying @postel/core instance for now; reach for the Promise API there if you need them.

On this page