Skip to content
DomainKit
Esc
navigateopen⌘Jpreview
On this page

Controllers

The four hooks behind the flow, the tagged states they expose, the commands they take, and how an attempt is abandoned when its inputs change.

Every part of the flow is a hook. Each takes one options object and returns a controller whose state is a tagged union, so a screen renders from state._tag and never from a boolean pair.

/**
 * Compose the flow yourself from the same hooks `Domain.Flow` uses. Each takes one options object
 * and returns a controller whose `state` is a tagged union.
 */
export function DomainRow({ domain }: { readonly domain: string }) {
  const connection = Connect.useController({ domain });
  const provisioning = Provision.useController({ domain, requirements });
  const cleanup = Cleanup.useController({ domain });
  const verification = Verify.useController({ domain, polling: true });

  if (connection.state._tag !== "Connected") {
    return <Connect.Dialog controller={connection} />;
  }
  return (
    <section>
      <Records.Table readiness={verification.readiness} records={requirements} />
      <Provision.Actions controller={provisioning} />
      <Cleanup.Actions controller={cleanup} />
    </section>
  );
}

Connection

Connect.useController({ domain, returnTo? })

State Means
Loading Inspecting the domain
Disconnected No connection. Carries discovery when one of the owner’s connections reaches the domain
Connected Attached and usable
Reconnect The credential can no longer be refreshed
Submitting A connect, attach, detach, or disconnect is in flight
Redirecting Sending the customer to the provider
SelectionRequired The credential reaches several matching zones
Failure Carries the DomainKit.Error
Command Does
connect({ provider, method, values?, returnTo? }) Starts a connection
reuse({ connectionId, zone? }) Attaches the domain to a connection the owner already has
select(zone) Answers SelectionRequired
detach() / disconnect() Releases the domain, or the whole connection
refresh() / retry() Re-inspects, or re-runs the step that failed

Discovery runs on mount whenever the transport declares it and the domain has no connection yet. A discovery failure leaves discovery null and the provider list still renders: discovery is an optimisation, not a step the customer asked for.

returnTo is where an interactive method sends the customer back to. It defaults to the page they started from, read when they connect rather than when the controller renders, so a flow mounted on one screen and used on another still returns to the right place. Pass null to send none and leave the server’s defaultReturnTo in charge; a per-call connect({ returnTo }) still wins.

Provisioning and cleanup

Provision.useController({ domain, requirements, onApplied? }) and Cleanup.useController({ domain, receiptId?, onCleaned? }) run the same machine.

State Means
Idle Nothing planned yet
Planning Building the plan
Planned Carries the plan, waiting for the customer
Approving Recording consent
Applying Writing
Applied Carries the receipt, complete or partial
Rejecting Recording a refusal
Rejected Carries the closed attempt
Failure Carries the DomainKit.Error
/**
 * `approve` authorizes the digest and applies it in one action. `reject` records the refusal and
 * is terminal. `retry` re-plans when the reason says the old plan is gone and re-runs the failed
 * step otherwise.
 */
export function ReviewActions({ domain }: { readonly domain: string }) {
  const provisioning = Provision.useController({ domain, requirements });
  const state = provisioning.state;
  if (state._tag !== "Planned") return null;
  return (
    <div>
      <button onClick={() => provisioning.approve()} type="button">
        Approve {state.plan.operations.length} change(s)
      </button>
      <button onClick={() => provisioning.reject("Not now")} type="button">
        Decline
      </button>
    </div>
  );
}

approve(operationIds?) authorizes the digest and applies it in one customer action, because the review screen offers Approve and Decline rather than Approve and then Apply. apply() stays available for a host that approves out of band. reject(reason?) is terminal: approving that plan afterwards fails Stale.

retry() builds a new plan when the reason says the old one is gone (Stale, Expired, Conflict) and re-runs the failed step otherwise.

Verification

Verify.useController({ domain, polling?, requirements? }) observes on mount, then re-observes at the readiness row’s own nextCheckAt while it stays mounted. readiness holds the latest observation even while a new one runs, so the table does not blink.

requirements says what to look for. Without it the server uses the attachment’s latest provisioning receipt, so a domain with nothing attached has nothing to observe; with it, that domain still reports which records are in place. The set identifies itself by content, so writing the array inline does not send the mount effect observing in a loop.

/**
 * The flow observes the requirements it was given, so a domain with no provider attached still
 * reports which records are in place.
 */
export function UnattachedStatus({ domain }: { readonly domain: string }) {
  const controller = Verify.useController({ domain, requirements });
  return <Verify.Status controller={controller} />;
}

A requirement that is not satisfied renders what it expected, what each observer found for that name, and the observer’s own detail line. An observer whose status is unknown never answered, so it reports no values, and host evidence carries none either.

export function DnsStatus() {
  const controller = Verify.useController({ domain });
  return <Verify.Status controller={controller} />;
}

Abandoning an attempt

A controller’s plan, approval, and receipt only mean something for the inputs that produced them, so changing domain or requirements drops them. The identity is the content of the requirement records, not array identity, so writing requirements={[...]} inline does not lose a plan the customer is reading.

Every command carries the key it was raised for and refuses once that key has moved, so a command raised from a layout effect in the same commit cannot act on the previous domain.

Failures

/** A failure keeps the `DomainKit.Error`, so read `reason`, `category`, and `isRetryable`. */
export function ConnectionProblem({ domain }: { readonly domain: string }) {
  const { messages } = DomainKit.useDomainKit();
  const connection = Connect.useController({ domain });
  const state = connection.state;
  if (state._tag !== "Failure") return null;
  return (
    <p role="alert">
      {Messages.failure(state.error, messages)}
      {state.error.isRetryable ? (
        <button onClick={connection.retry} type="button">
          Try again
        </button>
      ) : null}
    </p>
  );
}

The failed state carries the DomainKit.Error itself, so a host reads reason, category, and isRetryable instead of parsing text. Messages.failure(error, catalog) picks the sentence.

Capabilities and permission

/** Read what the server actually mounted before offering a step it cannot serve. */
export function CleanupButton({ domain }: { readonly domain: string }) {
  const capabilities = DomainKit.useCapabilities();
  const cleanup = Cleanup.useController({ domain });
  if (!capabilities.includes("cleanup")) return null;
  return (
    <button onClick={cleanup.plan} type="button">
      Remove records
    </button>
  );
}

Capabilities say what the server can serve. readOnly says what this customer may do with it, which no transport can express. A part of your own reads it through DomainKit.useReadOnly(), and DomainKit.ReadOnly narrows one subtree without touching the rest of the page.

/** A part of your own asks which mode it is in. */
export function RemoveDomainButton({ onRemove }: { readonly onRemove: () => void }) {
  const readOnly = DomainKit.useReadOnly();
  if (readOnly) return null;
  return (
    <button onClick={onRemove} type="button">
      Remove this domain
    </button>
  );
}

/** Or narrow one subtree without touching the rest of the page. */
export function ReadOnlyRecords() {
  return (
    <DomainKit.ReadOnly value={true}>
      <Records.Table records={requirements} />
    </DomainKit.ReadOnly>
  );
}

Read-only removes a write surface rather than disabling it, and retry goes with it: re-running a failed write is still a write. Re-inspecting and observing stay, because both only read.

Was this page helpful?