sdxc

Type to search, or start from one of these:

@sdxc/hostname

Cloudflare for SaaS custom-hostname client: register, poll and delete customer domains

npm add @sdxc/hostname
pnpm add @sdxc/hostname
yarn add @sdxc/hostname
bun add @sdxc/hostname
Installs with
@sdxc/api-client
Depends on
remix
Used by
auth-saas

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 platformDomaincreateDefaultSubdomain("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.