@sdxc/hostname
Cloudflare for SaaS custom-hostname client: register, poll and delete customer domains
- Installs with
- @sdxc/api-client
- Depends on
remix- Used by
- auth-saas
- Source
- packages/hostname
Cloudflare for SaaS custom-hostname client: register, poll and delete customer domains.
A SaaS that lets customers bring their own domain registers each one as a custom hostname on a Cloudflare zone, hands the customer a DNS TXT record to prove ownership, and waits for the certificate to issue. This package is that conversation with the API: one client bound to one zone, with every response validated before it is returned.
Installation
npm add @sdxc/hostname
The client needs a Cloudflare API token allowed to edit the zone's custom hostnames, plus the id of that zone.
Usage
Register A Customer Domain
import { HostnameClient } from "@sdxc/hostname";
let client = new HostnameClient({
apiToken: process.env.CF_API_TOKEN,
zoneId: process.env.CF_ZONE_ID,
metadataKey: "account_id",
});
let hostname = await client.create("www.customer.example", accountId);
let record = HostnameClient.getValidationTxtRecord(hostname);
if (record) {
record.name; // TXT record name the customer adds to their DNS
record.value; // TXT record value
}
create asks for DV certificates validated over TXT, so the record above is what the
customer publishes to prove they own the domain.
Poll Until It Goes Live
let latest = await client.status(hostname.id);
HostnameClient.isActive(latest); // hostname and certificate are both active
HostnameClient.getStatusMessage(latest); // "Pending DNS validation"
getStatusMessage turns the two status fields into one sentence you can show the
customer, including the first validation error when Cloudflare reports one.
Find And Remove A Domain
let existing = await client.getByName("www.customer.example");
let owned = await client.listByEntity(accountId);
if (existing) await client.delete(existing.id);
getByName asks Cloudflare for that one hostname, while listByEntity reads every page of
the zone and keeps the hostnames tagged with the entity id.
Handle A Failed Call
import { HostnameApiError, HostnameClient } from "@sdxc/hostname";
try {
await client.delete(id);
} catch (error) {
if (error instanceof HostnameApiError && error.statusCode === 404) {
// the hostname is already gone
} else throw error;
}
API
new HostnameClient(options: HostnameClientOptions)
A client bound to one Cloudflare zone. apiToken and zoneId are required;
platformDomain is the apex createDefaultSubdomain builds on, and metadataKey is the
custom_metadata key that tags the owning entity, defaulting to "tenant_id".
client.create(hostname: string, entityId: string, region?: string): Promise<HostnameResult>
Registers a custom hostname with DV/TXT SSL validation and a minimum TLS version of 1.2,
storing entityId under the configured metadata key. region is a location hint kept in
custom_metadata.region, defaulting to "wnam".
client.status(id: string): Promise<HostnameResult>
Reads one custom hostname by its Cloudflare id, which is how activation and certificate progress are polled.
client.getByName(hostname: string): Promise<HostnameResult | null>
Looks a custom hostname up by the domain itself, answering null when the zone carries no
such hostname.
client.listByEntity(entityId: string): Promise<HostnameResult[]>
Every custom hostname tagged with that entity id. Cloudflare filters on hostname rather than
on custom_metadata, so this walks the zone's pages and matches the metadata key itself.
client.delete(id: string): Promise<void>
Removes a custom hostname from the zone.
client.refresh(id: string): Promise<HostnameResult>
Re-arms DV/TXT validation for a hostname and returns it with the current validation records, which is what issues a fresh TXT record when the customer never published the first one.
client.createDefaultSubdomain(slug: string): string
The slug's subdomain under the configured platformDomain — createDefaultSubdomain("acme")
is "acme.saas.example". A subdomain of your own apex is served by the zone already, so it
needs no custom hostname. Calling this without a platformDomain throws a TypeError.
HostnameClient.isActive(result: HostnameResult): boolean
Whether both the hostname status and the SSL status read "active", which is the point the
domain serves traffic.
HostnameClient.isPendingValidation(result: HostnameResult): boolean
Whether the hostname is still "pending" or the certificate is "pending_validation", so
the customer still has DNS work to do.
HostnameClient.getValidationTxtRecord(result: HostnameResult): { name: string; value: string } | null
The TXT record the customer publishes, available while the certificate is
pending_validation and null otherwise.
HostnameClient.getStatusMessage(result: HostnameResult): string
One human-readable line for the current state: "Active", a pending stage such as
"SSL certificate being issued", or "Validation failed: …" carrying Cloudflare's first
validation error.
HostnameApiError
Thrown when Cloudflare answers with an error or with a payload that fails validation.
statusCode is the HTTP status, errors carries Cloudflare's own { code, message }
entries when the response had them, and name is "CloudflareApiError".
Types
HostnameResult
What every call answers with: the validation fields flattened for status screens, plus a
nested ssl view of the same data.
interface HostnameResult {
id: string;
hostname: string;
status: string;
sslStatus: string | null;
validationTxtName: string | null;
validationTxtValue: string | null;
sslValidationErrors: Array<{ message: string }>;
createdAt: string | null;
entityId: string | null;
region: string | null;
ssl: {
status: string | null;
validationRecords: SSLValidationRecord[];
validationErrors: Array<{ message: string }>;
};
}
validationTxtName and validationTxtValue read the first SSL validation record, falling
back to the ownership-verification record Cloudflare returns instead for some hostnames.
HostnameClientOptions
interface HostnameClientOptions {
apiToken: string;
zoneId: string;
platformDomain?: string;
metadataKey?: string;
}
SSLValidationRecord
One DV record as Cloudflare names it: { txt_name: string; txt_value: string }.
CustomHostname
The raw hostname object as the API returns it — id, hostname, status, ssl, and the
optional custom_metadata, ownership_verification and created_at fields.
Pattern: Walking A Customer Through Setup
Registration and activation are two moments separated by the customer editing their DNS, so
the flow is one create that yields a record to display, then status on a schedule until
it goes live:
import { HostnameClient } from "@sdxc/hostname";
let client = new HostnameClient({
apiToken: process.env.CF_API_TOKEN,
zoneId: process.env.CF_ZONE_ID,
metadataKey: "account_id",
});
export async function startDomainSetup(accountId: string, domain: string) {
let result = await client.create(domain, accountId);
let record = HostnameClient.getValidationTxtRecord(result);
return {
id: result.id,
message: HostnameClient.getStatusMessage(result),
instructions: record && `Add a TXT record ${record.name} with the value ${record.value}`,
};
}
export async function checkDomainSetup(id: string) {
let result = await client.status(id);
if (HostnameClient.isActive(result)) return { live: true };
return { live: false, message: HostnameClient.getStatusMessage(result) };
}
A customer who let the record expire before publishing it gets a new one from
client.refresh(id), which returns the hostname with fresh validation records.
Pattern: Mirroring Hostnames In Your Own Database
Cloudflare holds the certificate state and your database holds everything around it, so a row is written from the result of the call that created the hostname and updated from each poll:
import { HostnameClient } from "@sdxc/hostname";
let result = await client.create(domain, accountId, "weur");
let record = HostnameClient.getValidationTxtRecord(result);
await db.domains.insert({
id: result.id,
accountId,
hostname: result.hostname,
status: result.status,
sslStatus: result.sslStatus,
validationTxtName: record?.name ?? null,
validationTxtValue: record?.value ?? null,
createdAt: result.createdAt,
});
listByEntity(accountId) reads the same set back from Cloudflare, which is what reconciles
the rows after a write that never landed.
Pattern: One Client Per Zone
The client holds configuration rather than connections, so a module that builds it once and exports it gives every caller the same zone and the same metadata key:
import { HostnameClient } from "@sdxc/hostname";
export let hostnames = new HostnameClient({
apiToken: process.env.CF_API_TOKEN,
zoneId: process.env.CF_ZONE_ID,
platformDomain: "saas.example",
metadataKey: "account_id",
});
The metadata key belongs here because create writes it and listByEntity filters on it:
one place to set it keeps those two agreeing.