doomain 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import type { DnsRecordInput, DnsRecordType } from './providers/types.js';
2
+ export interface DnsAnswer {
3
+ ttl?: number | null;
4
+ ttlUnavailableReason?: string;
5
+ value: string;
6
+ }
7
+ export interface DnsResolverObservation {
8
+ answers: DnsAnswer[];
9
+ elapsedMs: number;
10
+ error?: string;
11
+ errorCode?: string;
12
+ expected?: string;
13
+ kind: 'public' | 'system';
14
+ matches?: boolean;
15
+ resolver: string;
16
+ servers: string[];
17
+ type: DnsRecordType;
18
+ }
19
+ export type DnsPropagationStatus = 'local_or_vpn_cache_stale' | 'not_checked' | 'propagated' | 'public_propagation_pending' | 'system_resolver_unavailable';
20
+ export interface DnsPropagationResult {
21
+ elapsedMs: number;
22
+ expected: string;
23
+ observations: DnsResolverObservation[];
24
+ status: DnsPropagationStatus;
25
+ timeoutReason?: string;
26
+ }
27
+ export interface DnsResolverSpec {
28
+ kind: 'public' | 'system';
29
+ name: string;
30
+ servers?: string[];
31
+ }
32
+ export declare const defaultDnsResolvers: DnsResolverSpec[];
33
+ export declare function isNegativeDnsObservation(observation: DnsResolverObservation): boolean;
34
+ type ResolveTarget = Pick<DnsRecordInput, 'type' | 'value'>;
35
+ export declare function observeDnsRecord(fqdn: string, target: ResolveTarget, specs?: DnsResolverSpec[], elapsedMs?: number): Promise<DnsResolverObservation[]>;
36
+ export declare function classifyDnsPropagation(observations: DnsResolverObservation[]): DnsPropagationStatus;
37
+ export declare function waitForDnsPropagation(input: {
38
+ fqdn: string;
39
+ record: ResolveTarget;
40
+ timeoutSeconds: number;
41
+ observe?: (fqdn: string, target: ResolveTarget, elapsedMs: number) => Promise<DnsResolverObservation[]>;
42
+ now?: () => number;
43
+ sleep?: (milliseconds: number) => Promise<void>;
44
+ }): Promise<DnsPropagationResult>;
45
+ export {};
@@ -0,0 +1,114 @@
1
+ import { getServers, Resolver, resolve4, resolve6, resolveCname } from 'node:dns/promises';
2
+ import { normalizeDnsValue } from './dns-records.js';
3
+ export const defaultDnsResolvers = [
4
+ { kind: 'system', name: 'system' },
5
+ { kind: 'public', name: 'cloudflare', servers: ['1.1.1.1', '1.0.0.1'] },
6
+ { kind: 'public', name: 'google', servers: ['8.8.8.8', '8.8.4.4'] },
7
+ ];
8
+ const negativeAnswerCodes = new Set(['ENODATA', 'ENOTFOUND']);
9
+ export function isNegativeDnsObservation(observation) {
10
+ return observation.errorCode !== undefined && negativeAnswerCodes.has(observation.errorCode);
11
+ }
12
+ function resolverFor(spec) {
13
+ if (!spec.servers)
14
+ return undefined;
15
+ const resolver = new Resolver();
16
+ resolver.setServers(spec.servers);
17
+ return resolver;
18
+ }
19
+ async function queryResolver(fqdn, type, resolver) {
20
+ if (type === 'A') {
21
+ const answers = resolver ? await resolver.resolve4(fqdn, { ttl: true }) : await resolve4(fqdn, { ttl: true });
22
+ return answers.map((answer) => ({ ttl: answer.ttl, value: answer.address }));
23
+ }
24
+ if (type === 'AAAA') {
25
+ const answers = resolver ? await resolver.resolve6(fqdn, { ttl: true }) : await resolve6(fqdn, { ttl: true });
26
+ return answers.map((answer) => ({ ttl: answer.ttl, value: answer.address }));
27
+ }
28
+ if (type === 'CNAME') {
29
+ const answers = resolver ? await resolver.resolveCname(fqdn) : await resolveCname(fqdn);
30
+ return answers.map((value) => ({
31
+ ttl: null,
32
+ ttlUnavailableReason: 'resolver_api_does_not_expose_cname_ttl',
33
+ value,
34
+ }));
35
+ }
36
+ throw new Error(`Resolver observation is not supported for ${type} records.`);
37
+ }
38
+ export async function observeDnsRecord(fqdn, target, specs = defaultDnsResolvers, elapsedMs = 0) {
39
+ return Promise.all(specs.map(async (spec) => {
40
+ const servers = spec.servers ?? getServers();
41
+ try {
42
+ const answers = await queryResolver(fqdn, target.type, resolverFor(spec));
43
+ return {
44
+ answers,
45
+ elapsedMs,
46
+ expected: target.value,
47
+ kind: spec.kind,
48
+ matches: answers.length > 0 &&
49
+ answers.every((answer) => normalizeDnsValue(answer.value) === normalizeDnsValue(target.value)),
50
+ resolver: spec.name,
51
+ servers,
52
+ type: target.type,
53
+ };
54
+ }
55
+ catch (error) {
56
+ const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
57
+ ? error.code
58
+ : undefined;
59
+ return {
60
+ answers: [],
61
+ elapsedMs,
62
+ error: error instanceof Error ? error.message : String(error),
63
+ ...(errorCode ? { errorCode } : {}),
64
+ expected: target.value,
65
+ kind: spec.kind,
66
+ matches: false,
67
+ resolver: spec.name,
68
+ servers,
69
+ type: target.type,
70
+ };
71
+ }
72
+ }));
73
+ }
74
+ export function classifyDnsPropagation(observations) {
75
+ const publicResults = observations.filter((observation) => observation.kind === 'public' && (!observation.error || isNegativeDnsObservation(observation)));
76
+ const system = observations.find((observation) => observation.kind === 'system');
77
+ const publicMatches = publicResults.length > 0 && publicResults.every((observation) => observation.matches);
78
+ if (!publicMatches)
79
+ return 'public_propagation_pending';
80
+ if (!system || (system.error && !isNegativeDnsObservation(system)))
81
+ return 'system_resolver_unavailable';
82
+ if (!system.matches)
83
+ return 'local_or_vpn_cache_stale';
84
+ return 'propagated';
85
+ }
86
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
87
+ export async function waitForDnsPropagation(input) {
88
+ const now = input.now ?? Date.now;
89
+ const wait = input.sleep ?? sleep;
90
+ const started = now();
91
+ const deadline = started + input.timeoutSeconds * 1000;
92
+ let observations = [];
93
+ while (true) {
94
+ const elapsedMs = now() - started;
95
+ observations = input.observe
96
+ ? await input.observe(input.fqdn, input.record, elapsedMs)
97
+ : await observeDnsRecord(input.fqdn, input.record, defaultDnsResolvers, elapsedMs);
98
+ const status = classifyDnsPropagation(observations);
99
+ if (status === 'propagated' || status === 'local_or_vpn_cache_stale' || status === 'system_resolver_unavailable') {
100
+ return { elapsedMs, expected: input.record.value, observations, status };
101
+ }
102
+ const remaining = deadline - now();
103
+ if (remaining <= 0) {
104
+ return {
105
+ elapsedMs: now() - started,
106
+ expected: input.record.value,
107
+ observations,
108
+ status,
109
+ timeoutReason: 'public_resolvers_did_not_match_before_timeout',
110
+ };
111
+ }
112
+ await wait(Math.min(5000, remaining));
113
+ }
114
+ }
@@ -0,0 +1,31 @@
1
+ import { type DnsRecordSelector } from './dns-records.js';
2
+ import type { DnsProvider, DnsRecord, DnsRecordInput, DnsZone } from './providers/types.js';
3
+ interface RetryDependencies {
4
+ now?: () => number;
5
+ sleep?: (milliseconds: number) => Promise<void>;
6
+ }
7
+ export interface ReconciliationResult {
8
+ appliedChanges: number;
9
+ attempts: number;
10
+ observed: DnsRecord[];
11
+ reconciled: true;
12
+ }
13
+ export declare function reconcileDesiredRecord(input: {
14
+ desired: DnsRecordInput;
15
+ intervalMs?: number;
16
+ provider: DnsProvider;
17
+ timeoutMs?: number;
18
+ zone: DnsZone;
19
+ progress?: (message: string) => void;
20
+ dependencies?: RetryDependencies;
21
+ }): Promise<ReconciliationResult>;
22
+ export declare function reconcileRecordRemoval(input: {
23
+ intervalMs?: number;
24
+ provider: DnsProvider;
25
+ selector: DnsRecordSelector;
26
+ timeoutMs?: number;
27
+ zone: DnsZone;
28
+ progress?: (message: string) => void;
29
+ dependencies?: RetryDependencies;
30
+ }): Promise<ReconciliationResult>;
31
+ export {};
@@ -0,0 +1,53 @@
1
+ import { desiredSlotPostcondition, recordMatchesSelector } from './dns-records.js';
2
+ import { DoomainError } from './errors.js';
3
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
4
+ export async function reconcileDesiredRecord(input) {
5
+ const now = input.dependencies?.now ?? Date.now;
6
+ const wait = input.dependencies?.sleep ?? sleep;
7
+ const deadline = now() + (input.timeoutMs ?? 30_000);
8
+ let attempts = 0;
9
+ const appliedChanges = 0;
10
+ while (true) {
11
+ attempts += 1;
12
+ const records = await input.provider.listRecords(input.zone);
13
+ const state = desiredSlotPostcondition(records, input.desired);
14
+ if (state.reconciled)
15
+ return { appliedChanges, attempts, observed: state.observed, reconciled: true };
16
+ if (now() >= deadline) {
17
+ throw new DoomainError('DNS_RECONCILIATION_INCOMPLETE', `The DNS provider accepted the change, but the ${input.desired.type} ${input.desired.name} slot is not reconciled yet.`, {
18
+ attempts,
19
+ expected: input.desired,
20
+ observed: state.observed,
21
+ recovery: 'Retry the command. Do not treat the DNS change as complete until reconciled is true.',
22
+ });
23
+ }
24
+ input.progress?.('Waiting for the DNS provider to publish the accepted change');
25
+ const remaining = deadline - now();
26
+ await wait(Math.min(input.intervalMs ?? 1000, Math.max(0, remaining)));
27
+ }
28
+ }
29
+ export async function reconcileRecordRemoval(input) {
30
+ const now = input.dependencies?.now ?? Date.now;
31
+ const wait = input.dependencies?.sleep ?? sleep;
32
+ const deadline = now() + (input.timeoutMs ?? 30_000);
33
+ let attempts = 0;
34
+ const appliedChanges = 0;
35
+ while (true) {
36
+ attempts += 1;
37
+ const records = await input.provider.listRecords(input.zone);
38
+ const observed = records.filter((record) => recordMatchesSelector(record, input.selector));
39
+ if (observed.length === 0)
40
+ return { appliedChanges, attempts, observed, reconciled: true };
41
+ if (now() >= deadline) {
42
+ throw new DoomainError('DNS_RECONCILIATION_INCOMPLETE', `The DNS provider accepted the deletion, but ${observed.length} matching record${observed.length === 1 ? '' : 's'} remain.`, {
43
+ attempts,
44
+ observed,
45
+ selector: input.selector,
46
+ recovery: 'Retry the command. Do not treat the DNS deletion as complete until reconciled is true.',
47
+ });
48
+ }
49
+ input.progress?.('Waiting for the DNS provider to publish the accepted deletion');
50
+ const remaining = deadline - now();
51
+ await wait(Math.min(input.intervalMs ?? 1000, Math.max(0, remaining)));
52
+ }
53
+ }
@@ -0,0 +1,18 @@
1
+ import type { DnsRecord, DnsRecordInput } from './providers/types.js';
2
+ export interface DnsRecordSelector {
3
+ name: string;
4
+ type: DnsRecord['type'];
5
+ value?: string;
6
+ }
7
+ export declare function normalizeDnsValue(value: string): string;
8
+ export declare function inferAddressRecordType(target: string): 'A' | 'AAAA' | 'CNAME';
9
+ export declare function normalizeAddressRecordTarget(recordType: 'A' | 'AAAA' | 'CNAME', value: string): string;
10
+ export declare function dnsRecordNamesEqual(a: string, b: string): boolean;
11
+ export declare function sameDnsRecordTarget(a: DnsRecord | DnsRecordInput, b: DnsRecord | DnsRecordInput): boolean;
12
+ export declare function sameDnsRecordValue(a: DnsRecord | DnsRecordInput, b: DnsRecord | DnsRecordInput): boolean;
13
+ export declare function recordsInNonTxtSlot(records: DnsRecord[], desired: DnsRecordInput): DnsRecord[];
14
+ export declare function desiredSlotPostcondition(records: DnsRecord[], desired: DnsRecordInput): {
15
+ observed: DnsRecord[];
16
+ reconciled: boolean;
17
+ };
18
+ export declare function recordMatchesSelector(record: DnsRecord, selector: DnsRecordSelector): boolean;
@@ -0,0 +1,73 @@
1
+ import { isIP } from 'node:net';
2
+ import { DoomainError } from './errors.js';
3
+ import { normalizeDomain } from './validate.js';
4
+ function normalizeIpv6(value) {
5
+ try {
6
+ return new URL(`http://[${value}]`).hostname.slice(1, -1);
7
+ }
8
+ catch {
9
+ return value.toLowerCase();
10
+ }
11
+ }
12
+ export function normalizeDnsValue(value) {
13
+ const cleaned = value.trim().toLowerCase().replace(/\.$/, '');
14
+ return isIP(cleaned) === 6 ? normalizeIpv6(cleaned) : cleaned;
15
+ }
16
+ export function inferAddressRecordType(target) {
17
+ const version = isIP(target);
18
+ if (version === 4)
19
+ return 'A';
20
+ if (version === 6)
21
+ return 'AAAA';
22
+ return 'CNAME';
23
+ }
24
+ export function normalizeAddressRecordTarget(recordType, value) {
25
+ const target = value.trim().replace(/\.$/, '');
26
+ if (!target)
27
+ throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
28
+ const version = isIP(target);
29
+ if (recordType === 'A' && version !== 4)
30
+ throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
31
+ if (recordType === 'AAAA' && version !== 6)
32
+ throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
33
+ if (recordType === 'CNAME' && version !== 0)
34
+ throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
35
+ return recordType === 'CNAME' ? normalizeDomain(target) : target;
36
+ }
37
+ export function dnsRecordNamesEqual(a, b) {
38
+ return a.trim().toLowerCase().replace(/\.$/, '') === b.trim().toLowerCase().replace(/\.$/, '');
39
+ }
40
+ function comparableRecordValue(type, value) {
41
+ return type === 'TXT' ? value : normalizeDnsValue(value);
42
+ }
43
+ export function sameDnsRecordTarget(a, b) {
44
+ return (dnsRecordNamesEqual(a.name, b.name) &&
45
+ a.type === b.type &&
46
+ comparableRecordValue(a.type, a.value) === comparableRecordValue(b.type, b.value));
47
+ }
48
+ export function sameDnsRecordValue(a, b) {
49
+ return (sameDnsRecordTarget(a, b) &&
50
+ (b.ttl === undefined || a.ttl === b.ttl) &&
51
+ (b.priority === undefined || a.priority === b.priority) &&
52
+ (b.proxied === undefined || a.proxied === b.proxied));
53
+ }
54
+ export function recordsInNonTxtSlot(records, desired) {
55
+ if (desired.type === 'TXT')
56
+ return records.filter((record) => dnsRecordNamesEqual(record.name, desired.name) && record.type === 'TXT');
57
+ return records.filter((record) => dnsRecordNamesEqual(record.name, desired.name) &&
58
+ record.type !== 'TXT' &&
59
+ (record.type === desired.type || record.type === 'CNAME' || desired.type === 'CNAME'));
60
+ }
61
+ export function desiredSlotPostcondition(records, desired) {
62
+ const observed = recordsInNonTxtSlot(records, desired);
63
+ return {
64
+ observed,
65
+ reconciled: observed.length === 1 && sameDnsRecordValue(observed[0], desired),
66
+ };
67
+ }
68
+ export function recordMatchesSelector(record, selector) {
69
+ return (dnsRecordNamesEqual(record.name, selector.name) &&
70
+ record.type === selector.type &&
71
+ (selector.value === undefined ||
72
+ comparableRecordValue(record.type, record.value) === comparableRecordValue(selector.type, selector.value)));
73
+ }
@@ -151,7 +151,7 @@ async function loadConfiguredProviderZones(providerId, accountInput, toleratePro
151
151
  : ['doomain providers connect', 'doomain link <domain> --json'],
152
152
  });
153
153
  }
154
- const results = await Promise.all(providerAccounts.map(({ definition, ref }) => loadProviderZonesSafely(definition, ref)));
154
+ const results = await Promise.all(providerAccounts.map(({ definition, ref }) => tolerateProviderAccountErrors ? loadProviderZonesSafely(definition, ref) : loadProviderZones(definition, ref)));
155
155
  return {
156
156
  accountInferred: account === undefined,
157
157
  candidates: results.flatMap((result) => result.candidates),
@@ -1,4 +1,4 @@
1
- export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_POINT_FAILED' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_PROVIDER_DISCOVERY_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'SELF_UPDATE_FAILED' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
1
+ export type DoomainErrorCode = 'CONFIG_NOT_FOUND' | 'CLERK_AUTH_FAILED' | 'CLERK_PRODUCTION_EXISTS' | 'DNS_POINT_FAILED' | 'DNS_REMOVE_FAILED' | 'DNS_DELETE_AMBIGUOUS' | 'DNS_DIAGNOSE_FAILED' | 'DNS_RECONCILIATION_INCOMPLETE' | 'DNS_TARGET_CONFLICT' | 'DOMAIN_LINK_FAILED' | 'DOMAIN_PROVIDER_DISCOVERY_FAILED' | 'DOMAIN_ALREADY_ASSIGNED' | 'DOMAIN_VERIFY_FAILED' | 'INVALID_INPUT' | 'MISSING_ARGUMENT' | 'MISSING_CREDENTIALS' | 'PROVIDER_API_ERROR' | 'PROVIDER_AUTH_FAILED' | 'PROVIDER_PERMISSION_DENIED' | 'PROVIDER_NOT_FOUND' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_RECORD_CONFLICT' | 'PROVIDER_UNSUPPORTED_RECORD' | 'PROVIDER_ZONE_AMBIGUOUS' | 'PROVIDER_ZONE_NOT_FOUND' | 'PROJECT_NOT_FOUND' | 'SELF_UPDATE_FAILED' | 'VERCEL_AUTH_FAILED' | 'VERCEL_PROJECT_NOT_LINKED';
2
2
  export declare class DoomainError extends Error {
3
3
  readonly code: DoomainErrorCode;
4
4
  readonly details?: unknown;
@@ -1,3 +1,4 @@
1
+ import { type DnsPropagationResult, type DnsResolverObservation } from './dns-propagation.js';
1
2
  import { type ResolvedDnsTarget } from './domain-provider.js';
2
3
  import { type DnsOverrideWarning } from './link-domain.js';
3
4
  import type { DnsProvider, DnsRecordInput } from './providers/types.js';
@@ -13,6 +14,7 @@ export interface PointDomainInput {
13
14
  timeoutSeconds?: number;
14
15
  ttl?: number;
15
16
  wait?: boolean;
17
+ reconcileTimeoutSeconds?: number;
16
18
  confirmDnsOverride?: (warning: DnsOverrideWarning) => Promise<boolean>;
17
19
  progress?: (message: string) => void;
18
20
  }
@@ -25,6 +27,9 @@ export interface PointDomainResult {
25
27
  provider: string;
26
28
  providerInferred: boolean;
27
29
  propagated: boolean;
30
+ propagation: DnsPropagationResult;
31
+ reconciled: boolean;
32
+ reconciliationAttempts: number;
28
33
  record: DnsRecordInput;
29
34
  skipped: DnsRecordInput[];
30
35
  updated: boolean;
@@ -34,9 +39,7 @@ interface PointDomainDependencies {
34
39
  createProvider: (provider: string, opts: {
35
40
  account?: string;
36
41
  }) => Promise<DnsProvider>;
37
- resolve4?: (hostname: string) => Promise<string[]>;
38
- resolve6?: (hostname: string) => Promise<string[]>;
39
- resolveCname?: (hostname: string) => Promise<string[]>;
42
+ observeDns?: (fqdn: string, target: Pick<DnsRecordInput, 'type' | 'value'>, elapsedMs: number) => Promise<DnsResolverObservation[]>;
40
43
  resolveTarget: (input: Pick<PointDomainInput, 'account' | 'domain' | 'provider'>) => Promise<ResolvedDnsTarget>;
41
44
  }
42
45
  export declare function createPointRecord(input: {
@@ -1,87 +1,27 @@
1
- import { resolve4, resolve6, resolveCname } from 'node:dns/promises';
2
- import { isIP } from 'node:net';
1
+ import { waitForDnsPropagation } from './dns-propagation.js';
2
+ import { reconcileDesiredRecord } from './dns-reconciliation.js';
3
+ import { inferAddressRecordType, normalizeAddressRecordTarget } from './dns-records.js';
3
4
  import { resolveProviderTarget } from './domain-provider.js';
4
5
  import { DoomainError } from './errors.js';
5
6
  import { withProviderRecordOptions } from './link-domain.js';
6
7
  import { createProvider } from './providers/registry.js';
7
- import { normalizeDomain } from './validate.js';
8
8
  const defaultDependencies = {
9
9
  createProvider,
10
10
  resolveTarget: resolveProviderTarget,
11
11
  };
12
- function cleanTarget(value) {
13
- return value.trim().replace(/\.$/, '');
14
- }
15
- function inferredRecordType(target) {
16
- const version = isIP(target);
17
- if (version === 4)
18
- return 'A';
19
- if (version === 6)
20
- return 'AAAA';
21
- return 'CNAME';
22
- }
23
- function validateTarget(recordType, target) {
24
- const version = isIP(target);
25
- if (recordType === 'A' && version !== 4)
26
- throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
27
- if (recordType === 'AAAA' && version !== 6)
28
- throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
29
- if (recordType === 'CNAME' && version !== 0)
30
- throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
31
- }
32
12
  export function createPointRecord(input) {
33
- const target = cleanTarget(input.target);
34
- if (!target)
35
- throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
36
- const recordType = input.recordType ?? inferredRecordType(target);
37
- validateTarget(recordType, target);
13
+ const recordType = input.recordType ?? inferAddressRecordType(input.target.trim());
14
+ const target = normalizeAddressRecordTarget(recordType, input.target);
38
15
  return withProviderRecordOptions(input.provider, {
39
16
  name: input.recordName,
40
17
  ttl: input.ttl ?? 300,
41
18
  type: recordType,
42
- value: recordType === 'CNAME' ? normalizeDomain(target) : target,
19
+ value: target,
43
20
  });
44
21
  }
45
22
  function recordFqdn(record, zoneDomain) {
46
23
  return record.name === '@' ? zoneDomain : `${record.name}.${zoneDomain}`;
47
24
  }
48
- function cleanDnsValue(value) {
49
- return value.toLowerCase().replace(/\.$/, '');
50
- }
51
- function normalizeIpv6(value) {
52
- return new URL(`http://[${value}]`).hostname.slice(1, -1);
53
- }
54
- async function isPropagated(record, zoneDomain, dependencies) {
55
- const fqdn = recordFqdn(record, zoneDomain);
56
- try {
57
- if (record.type === 'A')
58
- return (await (dependencies.resolve4 ?? resolve4)(fqdn)).includes(record.value);
59
- if (record.type === 'AAAA') {
60
- const expected = normalizeIpv6(record.value);
61
- return (await (dependencies.resolve6 ?? resolve6)(fqdn)).some((value) => normalizeIpv6(value) === expected);
62
- }
63
- if (record.type === 'CNAME') {
64
- return (await (dependencies.resolveCname ?? resolveCname)(fqdn))
65
- .map(cleanDnsValue)
66
- .includes(cleanDnsValue(record.value));
67
- }
68
- }
69
- catch {
70
- return false;
71
- }
72
- return false;
73
- }
74
- async function waitForPropagation(record, zoneDomain, timeoutSeconds, dependencies) {
75
- const deadline = Date.now() + timeoutSeconds * 1000;
76
- while (true) {
77
- if (await isPropagated(record, zoneDomain, dependencies))
78
- return true;
79
- const remaining = deadline - Date.now();
80
- if (remaining <= 0)
81
- return false;
82
- await new Promise((resolve) => setTimeout(resolve, Math.min(5000, remaining)));
83
- }
84
- }
85
25
  function conflictWarning(resolved, provider, record, conflicts) {
86
26
  return {
87
27
  account: resolved.account,
@@ -170,6 +110,14 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
170
110
  provider: resolved.provider,
171
111
  providerInferred: resolved.providerInferred,
172
112
  propagated: false,
113
+ propagation: {
114
+ elapsedMs: 0,
115
+ expected: record.value,
116
+ observations: [],
117
+ status: 'not_checked',
118
+ },
119
+ reconciled: false,
120
+ reconciliationAttempts: 0,
173
121
  record,
174
122
  skipped: [],
175
123
  updated: false,
@@ -191,10 +139,25 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
191
139
  }
192
140
  input.progress?.(`Pointing ${resolved.target.fullDomain} to ${record.value}`);
193
141
  const result = await provider.applyChanges(zone, plan, { force });
142
+ const reconciliation = await reconcileDesiredRecord({
143
+ desired: record,
144
+ progress: input.progress,
145
+ provider,
146
+ timeoutMs: (input.reconcileTimeoutSeconds ?? 30) * 1000,
147
+ zone,
148
+ });
194
149
  const shouldWait = input.wait ?? true;
195
- const propagated = shouldWait
196
- ? await waitForPropagation(record, resolved.target.zoneDomain, input.timeoutSeconds ?? 300, dependencies)
197
- : false;
150
+ const propagation = shouldWait
151
+ ? await waitForDnsPropagation({
152
+ fqdn: recordFqdn(record, resolved.target.zoneDomain),
153
+ observe: dependencies.observeDns,
154
+ record,
155
+ timeoutSeconds: input.timeoutSeconds ?? 300,
156
+ })
157
+ : { elapsedMs: 0, expected: record.value, observations: [], status: 'not_checked' };
158
+ const propagated = propagation.status === 'propagated' ||
159
+ propagation.status === 'local_or_vpn_cache_stale' ||
160
+ propagation.status === 'system_resolver_unavailable';
198
161
  return {
199
162
  account: resolved.account,
200
163
  accountInferred: resolved.accountInferred,
@@ -204,9 +167,12 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
204
167
  provider: resolved.provider,
205
168
  providerInferred: resolved.providerInferred,
206
169
  propagated,
170
+ propagation,
171
+ reconciled: reconciliation.reconciled,
172
+ reconciliationAttempts: reconciliation.attempts,
207
173
  record,
208
174
  skipped: result.skipped,
209
- updated: result.applied.length > 0,
175
+ updated: result.applied.length > 0 || reconciliation.appliedChanges > 0,
210
176
  zoneDomain: resolved.target.zoneDomain,
211
177
  };
212
178
  }
@@ -19,5 +19,6 @@ export declare class HostingerProvider implements DnsProvider {
19
19
  upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
20
20
  deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
21
21
  private putRecords;
22
+ private putHostingerRecordSets;
22
23
  }
23
24
  export declare const hostingerProviderDefinition: DnsProviderDefinition;
@@ -47,7 +47,7 @@ function toDnsRecords(record, zone) {
47
47
  return [];
48
48
  return [
49
49
  {
50
- metadata: { hostinger: { ...record, records: [item] } },
50
+ metadata: { hostinger: record },
51
51
  name,
52
52
  ttl: record.ttl,
53
53
  type,
@@ -56,12 +56,13 @@ function toDnsRecords(record, zone) {
56
56
  ];
57
57
  });
58
58
  }
59
- function toHostingerRecord(record) {
59
+ function toHostingerRecordSet(records) {
60
+ const first = records[0];
60
61
  return {
61
- name: record.name,
62
- records: [{ content: record.value }],
63
- ttl: record.ttl ?? capabilities.defaultTtl,
64
- type: record.type,
62
+ name: first.name,
63
+ records: records.map((record) => ({ content: record.value })),
64
+ ttl: first.ttl ?? capabilities.defaultTtl,
65
+ type: first.type,
65
66
  };
66
67
  }
67
68
  function deleteFilter(record) {
@@ -142,15 +143,40 @@ export class HostingerProvider {
142
143
  assertNoConflicts(this.id, plan);
143
144
  const applied = [];
144
145
  const skipped = [];
146
+ const deletionSets = new Map();
147
+ for (const change of plan.changes) {
148
+ if (change.action !== 'delete')
149
+ continue;
150
+ const key = `${change.existing.type}\0${change.existing.name}`;
151
+ const records = deletionSets.get(key) ?? [];
152
+ records.push(change.existing);
153
+ deletionSets.set(key, records);
154
+ }
155
+ for (const deleted of deletionSets.values()) {
156
+ const sample = deleted[0];
157
+ const source = sample.metadata?.hostinger;
158
+ const deletedValues = new Set(deleted.map((record) => record.value));
159
+ const sourceRecords = source?.records?.filter((record) => record.is_disabled || !record.content || !deletedValues.has(record.content));
160
+ if (source && sourceRecords && sourceRecords.length > 0) {
161
+ await this.putHostingerRecordSets(zone, [{ ...source, records: sourceRecords }], true);
162
+ }
163
+ else {
164
+ const remaining = plan.existing.filter((record) => sameRecordSet(record, sample) && !deleted.includes(record));
165
+ if (remaining.length > 0)
166
+ await this.putRecords(zone, remaining, true);
167
+ else
168
+ await this.deleteRecord(zone, sample);
169
+ }
170
+ applied.push(...plan.changes.filter((change) => change.action === 'delete' && deleted.includes(change.existing)));
171
+ }
145
172
  for (const change of plan.changes) {
146
173
  if (change.action === 'skip') {
147
174
  skipped.push(change.record);
148
175
  continue;
149
176
  }
150
177
  if (change.action === 'delete')
151
- await this.deleteRecord(zone, change.existing);
152
- else
153
- await this.putRecords(zone, [change.record], change.action === 'update');
178
+ continue;
179
+ await this.putRecords(zone, [change.record], change.action === 'update');
154
180
  applied.push(change);
155
181
  }
156
182
  return { applied, skipped };
@@ -166,8 +192,18 @@ export class HostingerProvider {
166
192
  });
167
193
  }
168
194
  async putRecords(zone, records, overwrite) {
195
+ const recordSets = new Map();
196
+ for (const record of records) {
197
+ const key = `${record.type}\0${record.name}`;
198
+ const values = recordSets.get(key) ?? [];
199
+ values.push(record);
200
+ recordSets.set(key, values);
201
+ }
202
+ await this.putHostingerRecordSets(zone, [...recordSets.values()].map(toHostingerRecordSet), overwrite);
203
+ }
204
+ async putHostingerRecordSets(zone, recordSets, overwrite) {
169
205
  await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`, {
170
- body: { overwrite, zone: records.map(toHostingerRecord) },
206
+ body: { overwrite, zone: recordSets },
171
207
  method: 'PUT',
172
208
  });
173
209
  }