npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

domainkit

v0.18.0

Published

Build reviewable DNS plans for custom domains in TypeScript

Downloads

9,371

Readme

DomainKit

Add custom domains to a TypeScript SaaS application.

DomainKit turns DNS requirements into plans a customer can review, applies only the plan digest they approved, keeps a receipt of every write, and plans cleanup from that receipt. Cloudflare and Vercel are built in; a provider is one declarative value, so tokens-only providers are the same shape minus the OAuth case.

Install

npm install domainkit effect@rc

Node.js 24.10 or newer and Effect 4 are required.

Plan, approve, apply

import { Effect, Match } from "effect";
import { DnsRecord, DomainKit, Principal, Provision, Verify } from "domainkit";
import { Testing } from "domainkit/testing";

const requirements = [
  DnsRecord.cname({ name: "app.example.com", target: "edge.acme.dev", purpose: "Serve your site" }),
  DnsRecord.txt({
    name: "_acme.app.example.com",
    value: "acme-verify=7f3a",
    purpose: "Prove ownership",
  }),
];

const program = Effect.gen(function* () {
  const plan = yield* Provision.plan({ domain: "app.example.com", requirements });
  //    ^ operations: [Create CNAME, Noop TXT] with a digest the customer approves

  const approval = yield* Provision.approve(plan);
  const receipt = yield* Provision.apply(approval);
  //    ^ status: "complete" | "partial", one outcome per operation, safe to retry

  const readiness = yield* Verify.observe({ domain: "app.example.com" });
  return { receipt, ready: readiness.overall === "ready", nextCheckAt: readiness.nextCheckAt };
}).pipe(
  Effect.catchTag("DomainKitError", (error) =>
    Match.value(error.reason).pipe(
      Match.tag("Conflict", ({ operations }) =>
        Effect.fail(`Fix ${operations.length} conflicting record(s) first`),
      ),
      Match.tag("Stale", () => Effect.fail("Provider changed under us; plan again")),
      Match.orElse(() => Effect.fail(error.message)),
    ),
  ),
);

export const main = program.pipe(
  Effect.provideService(Principal.Service, { ownerId: "org_42", actorId: "user_7" }),
  Effect.provide(
    DomainKit.layerMemory({ providers: [Testing.provider({ zones: ["example.com"] })] }),
  ),
);

Plans are additive and fail closed: exact records are Noop, missing records are Create, and incompatible state is Conflict. DomainKit never updates or deletes a record it did not create, and cleanup is its own plan, approval, and receipt built from the apply receipt.

Several domains at once are one batch: Provision.batch.create plans them with Policy.batchConcurrency in flight, approve binds one digest over every plan, and apply walks the approved domains under their own attempt leases, so a second apply skips what the first holds and a domain that fails does not stop the ones beside it. A batch is resumable at every step — resumePlanning re-plans what failed, apply re-claims what failed — and Provision.batch.list({ unfinished: true }) is the index behind a "you still owe this" banner.

Every step is a stored attempt, so a host can render the plan in one request, collect consent in another, and apply in a third; retrying any step replays its result. A customer who declines calls Provision.reject, which closes the attempt for good and leaves the domain free for a new plan. Every failure is one DomainKit.Error whose reason you match on; category, isRetryable, and httpStatus derive from it.

Wire it into your app

import { Config, Layer } from "effect";
import { Cloudflare, Custody, DomainKit, Vercel } from "domainkit";

export const DomainKitLive = DomainKit.layer({
  providers: [
    Cloudflare.provider({
      oauth: {
        clientId: Config.String("CF_CLIENT_ID"),
        clientSecret: Config.Redacted("CF_CLIENT_SECRET"),
      },
    }),
    Vercel.provider(), // tokens only
  ],
}).pipe(
  // `provideMerge`, not `provide`: `domainkit/server`'s handlers read attempts and receipts
  // straight from Storage, so the layer they are given has to still carry it.
  Layer.provideMerge(Layer.mergeAll(YourStorage, Custody.layerConfig())),
);

The host provides two services beneath the layer and one per request:

  • Storage — every durable row (authorizations with sealed credentials, connections, attachments, continuations, attempts, readiness). Storage.layerMemory is for tests; the Postgres implementation lives in @domainkit/capsuledb; Storage.layerFromAsync wraps a Promise-shaped implementation of your own. Testing.conformance.storage checks any implementation.
  • Custody — seals credentials before Storage sees them. Custody.layerConfig() reads a 32-byte key from DOMAINKIT_CUSTODY_KEY; Custody.layerFromAsync wraps a KMS.
  • Principal — { ownerId, actorId } per request. Every Storage read and write is scoped by it, so cross-tenant access is a type error, not a runtime check.

Connect.start connects a provider (a token in one call, OAuth or a marketplace integration via a redirect and Connect.complete), attaches domains, refreshes credentials before they expire, and revokes them on disconnect. Verify.observe reads the provider and public DNS, stores readiness per requirement, and tells you when to look again; pass requirements to observe records a customer applies by hand on a domain with no attachment. The stored readiness is the single fact about a domain: Verify.latest reads it back without observing, Verify.summary counts it, and Verify.Observer tells your application when DomainKit wrote it, so the clock and the projection are yours and the copy is not.

Mount the routes

import { Effect, Layer } from "effect";
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
import { DomainKit, Reason } from "domainkit";
import { Server } from "domainkit/server";

// Verify a credential you issued and look the tenant up yourself. A request never names its own
// `ownerId`, and one you cannot attribute fails closed. Read it from a cookie: `/callback/:provider`
// is a browser navigation, so only what the browser sends by itself arrives with it.
const IdentityLive = Layer.succeed(Server.Identity)({
  principal: (request) =>
    Effect.flatMap(yourSessions.verify(request.cookies.session), (session) =>
      session === null
        ? Effect.fail(
            new DomainKit.Error({ reason: new Reason.Unauthenticated({ message: "No session" }) }),
          )
        : Effect.succeed({ ownerId: session.orgId, actorId: session.userId }),
    ),
  // Optional: which routes this principal may reach. Members read, administrators write.
  authorize: (principal, endpoint) =>
    principal.actorId === "admin" || !writeRoutes.has(endpoint)
      ? Effect.void
      : Effect.fail(
          new DomainKit.Error({
            reason: new Reason.Forbidden({ message: `${endpoint} needs an administrator` }),
          }),
        ),
});

export const Api = HttpApi.make("app").add(Server.group);

export const ApiLive = HttpApiBuilder.layer(Api).pipe(
  Layer.provide(Server.layer(Api, { defaultReturnTo: "/settings/domains" })),
  Layer.provide([DomainKitLive, IdentityLive]),
);

Server.group is one HttpApiGroup with twenty-five typed endpoints covering the whole lifecycle: inspect, discover, connect, callback, attach, detach, disconnect, plan, approve, reject, apply, read a plan or a receipt, observe, build a cleanup plan, and the seven batch routes under /batches. Identity is the only service you write, and every handler derives the Principal for the request it is serving. Server.group.prefix("/internal/dns") moves every route, and the OAuth callback URL follows the mount. OpenApi.fromApi(Server.api) documents the group.

/callback/:provider is the one route the provider drives the browser to, so Identity has to recognise a credential the browser sends on a top-level navigation.

authorize is optional and runs after principal on every request with the route's name, one of Server.EndpointName. Fail it with reason Forbidden for the 403 a UI expects. Omit it and every authenticated principal reaches every route, which is right when your own middleware already gates the mount.

After an interactive connection completes, the callback redirects to the returnTo the flow was started with, or to defaultReturnTo. The destination is resolved against the callback's own base and must land on its origin, so neither the provider nor a crafted returnTo can steer the customer off the application. Behind a proxy that rewrites Host, set callbackBaseUrl to the public base: the request origin is one the browser never sees, and both the provider's callback URL and the redirect follow the configured one.

Failures cross the wire as the DomainKit.Error value with the status its reason derives, so a Conflict is a 409 carrying the conflicting operations and a Reconnect is a 403 naming the connection.

Hosts that are not on Effect's HTTP stack use the Promise edge:

const { handler, dispose } = Server.toWebHandler(Layer.mergeAll(DomainKitLive, IdentityLive), {
  prefix: "/api/domainkit",
});

Talk to them from the browser

import { Transport } from "domainkit/client";

const transport = Transport.fromFetch("/api/domainkit");

// Effect at the call site, or `Transport.toAsync(transport)` for Promises.
const started =
  yield *
  transport.connection!.start({
    domain: "app.example.com",
    provider: "cloudflare",
    method: Transport.Method.oauth({ returnTo: "/settings/domains" }),
  });

connection.discover(domain) answers which of the customer's existing connections already reaches the domain, so a second domain on a connected provider skips the connect step entirely. When none does, NotFound.host names the registered provider whose declared nameserver suffixes cover the domain's delegation, so the connect screen can offer that provider first. Snapshot.providers[].methods[] carries each method's label, docs URL, and token fields, so a connect form renders from the response instead of hard-coding provider names.

Capability groups are optional. A host that mounts only the connection routes declares Transport.fromFetch(url, { capabilities: ["connection"] }), Transport.capabilities(transport) reports what is there, and the parts of @domainkit/react that plan or clean up do not render. Transport.fromAsync adapts a Promise-shaped transport of your own.

Failures arrive as the same DomainKit.Error the lifecycle raised, reason intact, so a Conflict still carries its conflicting operations. A response the transport cannot read as a DomainKit.Error becomes a retryable ProviderUnavailable naming the base URL.

Test against the seam

domainkit/testing ships Testing.provider (a token and OAuth provider over in-memory zones), Testing.resolver, Testing.storage, Testing.transport (the whole lifecycle over an in-memory server, recording every call), and the conformance runners, so host tests never stub global fetch. Provider authors run Testing.conformance.provider(definition, credential, zone) against a real account before shipping.

Public entry points

  • domainkit — the Effect-native root: lifecycle services, host seams, providers, and values;
  • domainkit/server — the mountable route group, its layers, and the wire schemas;
  • domainkit/client — the capability-gated fetch transport and its Promise adapters;
  • domainkit/testing — fakes and conformance runners;
  • domainkit/<Module> — every root namespace as its own subpath (domainkit/Principal, domainkit/DnsRecord, ...), the same module instances the root re-exports, for declaration emit and bundlers that want the declaring module.

Your app owns identity, tenancy, persistence, keys, routes, and consent. DomainKit supplies the lifecycle, not a hosted control plane.

Learn more

License

MIT