CloudflareDNS - TundraSoft/tundra-connect GitHub Wiki
CloudflareDNS
Typed Cloudflare DNS client for Deno, Bun, Node.js and Cloudflare Workers: list, create, update, replace, delete and batch-edit the DNS records of a zone, export the zone as a BIND file, and look up zones by name to find a zone id. Requests are validated locally, responses are unwrapped from Cloudflare's envelope and validated, and every failure is one typed error.
Overview
Cloudflare's DNS API lives under /zones/{zone_id}/dns_records in its
client/v4 REST API. This connect covers the record lifecycle a service
needs when it provisions subdomains, ACME challenges or customer CNAMEs:
- Records:
listRecords(filters, paging, ordering),getRecord,createRecord,updateRecord(PATCH),replaceRecord(PUT),deleteRecord, an atomicbatch, andexportRecords(BIND text). - Zones:
listZones(filter bynameto turn a domain into a zone id) andgetZone. - Every method targets the client's
zoneIdunless the call passes its own, so one client can serve one zone or many. - Cloudflare's
{success, errors, messages, result}envelope is unwrapped: methods resolve to the record, and list methods to{ result, result_info }for paging. - Failures map Cloudflare's numeric codes onto stable names:
RECORD_CONFLICTfor 81053/81056/81057,NOT_FOUNDfor 81044 and a bad zone id (7003),AUTH_FAILEDfor the 9xxx/10000 family, and so on.
import { CloudflareDNS } from '@tundraconnect/cloudflare-dns';
import { CloudflareDNSError } from '@tundraconnect/cloudflare-dns/errors';
const dns = new CloudflareDNS({
auth: { type: 'BEARER', token: 'YOUR_API_TOKEN' },
zoneId: 'YOUR_ZONE_ID',
});
try {
const record = await dns.createRecord({
type: 'CNAME',
name: 'acme.example.com',
content: 'app.example.com',
proxied: true,
comment: 'tenant: acme',
});
console.log('created', record.id);
} catch (err) {
if (err instanceof CloudflareDNSError && err.code === 'RECORD_CONFLICT') {
// an identical or conflicting record already exists
} else {
throw err;
}
}
// Find it again, change it, remove it.
const { result } = await dns.listRecords({
type: 'CNAME',
name: 'acme.example.com',
});
const id = result[0]!.id;
await dns.updateRecord({ recordId: id, content: 'app-v2.example.com' });
await dns.deleteRecord({ recordId: id });
// Many changes at once, applied atomically.
await dns.batch({
posts: [
{ type: 'A', name: 'a.example.com', content: '203.0.113.1' },
{ type: 'A', name: 'b.example.com', content: '203.0.113.2' },
],
});
// Which zone is example.com?
const zones = await dns.listZones({ name: 'example.com' });
console.log(zones.result[0]?.id);
Timeouts, network failures, 5xx and 429 surface as TIMEOUT,
NETWORK_ERROR, SERVICE_UNAVAILABLE and RATE_LIMITED, all flagged
transient: true, so "unreachable, retry later" is one check.
Endpoints
| Method | Request |
|---|---|
listZones({ name?, status?, … }) |
GET /zones |
getZone({ zoneId? }) |
GET /zones/{zone_id} |
listRecords({ type?, name?, … }) |
GET /zones/{zone_id}/dns_records |
getRecord({ recordId }) |
GET /zones/{zone_id}/dns_records/{id} |
createRecord({ type, name, content | data, … }) |
POST /zones/{zone_id}/dns_records |
updateRecord({ recordId, …changes }) |
PATCH /zones/{zone_id}/dns_records/{id} |
replaceRecord({ recordId, …record }) |
PUT /zones/{zone_id}/dns_records/{id} |
deleteRecord({ recordId }) |
DELETE /zones/{zone_id}/dns_records/{id} |
batch({ deletes?, patches?, puts?, posts? }) |
POST /zones/{zone_id}/dns_records/batch |
exportRecords() |
GET /zones/{zone_id}/dns_records/export |
Every record method also takes an optional zoneId that overrides the
client's. Zone creation/deletion, the BIND import, DNS scanning and
zone-level DNS settings are not wrapped.
API token
Create a token at dash.cloudflare.com/profile/api-tokens with:
- Zone → DNS → Edit on the zone(s) you manage (Read is enough for
listRecords,getRecordandexportRecords); - Zone → Zone → Read for
listZones/getZone.
A token that authenticates but lacks a permission fails as FORBIDDEN
rather than AUTH_FAILED. Cloudflare's global API rate limit is 1,200
requests per five minutes per user; a 429 surfaces as RATE_LIMITED with
retryAfterSeconds when Cloudflare sends a hint.
Custom transport
To route requests through your own transport (a test double, a tracing
wrapper), subclass the client and reassign the protected _fetch:
import {
CloudflareDNS,
type CloudflareDNSOptions,
} from '@tundraconnect/cloudflare-dns';
class RoutedDNS extends CloudflareDNS {
constructor(options: CloudflareDNSOptions, transport: typeof fetch) {
super(options);
this._fetch = transport;
}
}
Testing your code
Your tests don't need to fake HTTP. Have your code take a CloudflareDNS
instance and pass in a stand-in that returns the shapes from
@tundraconnect/cloudflare-dns/schemas or throws a real CloudflareDNSError:
import { CloudflareDNSError } from '@tundraconnect/cloudflare-dns/errors';
const outage = new CloudflareDNSError('SERVICE_UNAVAILABLE', { status: 503 });
console.log(outage.code);
To exercise the client itself against a fake transport, subclass it and
reassign the protected _fetch (see Custom transport above).
Documentation
| Topic | Description |
|---|---|
| API | Client configuration and endpoint methods |
| Errors | Error codes and diagnostic metadata |
| Schemas | Public Guardian schemas and inferred types |
Upstream
Installation
Deno:
deno add jsr:@tundraconnect/cloudflare-dns
Bun:
bunx jsr add @tundraconnect/cloudflare-dns
Node.js:
npx jsr add @tundraconnect/cloudflare-dns
License
MIT. Part of Tundra Connect, typed vendor API clients for Deno, Bun, Node.js and Cloudflare Workers.