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.