cloudflare-ddns-sync 4.0.0-beta.2 → 4.0.0-rc.2

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.
@@ -1,158 +1,110 @@
1
1
  import { ParseResultType, fromUrl, parseDomain } from 'parse-domain';
2
2
  import Cloudflare from 'cloudflare';
3
- import IPUtils from './ip-utils.js';
4
- const ipv4Regex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/u;
5
- const ipv6Regex = /^(?:(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-fA-F]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,1}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,2}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:(?:[0-9a-fA-F]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,3}(?:(?:[0-9a-fA-F]{1,4})))?::(?:(?:[0-9a-fA-F]{1,4})):)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,4}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,5}(?:(?:[0-9a-fA-F]{1,4})))?::)(?:(?:[0-9a-fA-F]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-fA-F]{1,4})):){0,6}(?:(?:[0-9a-fA-F]{1,4})))?::))))$/u;
3
+ import { isIP } from 'node:net';
4
+ import { SyncError } from './sync-error.js';
6
5
  export default class CloudflareClient {
7
6
  cloudflare;
8
7
  zoneMap = new Map();
9
- constructor(cloudflareOptions) {
10
- this.cloudflare = new Cloudflare(cloudflareOptions);
11
- this.updateZoneMap();
12
- }
13
- async syncRecord(record, ip) {
14
- const recordIds = await this.getRecordIdsForRecords([record]);
15
- const ipToUse = ip ? ip : await IPUtils.getIpv4();
16
- const zoneId = await this.getZoneIdByRecordName(record.name);
17
- const recordId = recordIds.get(this.getRecordIdMapKey(record));
18
- const recordExists = recordId !== undefined;
19
- if (recordExists) {
20
- const result = await this.updateRecord(zoneId, recordId, record, ipToUse);
21
- return result;
22
- }
23
- const result = await this.createRecord(zoneId, record, ipToUse);
24
- return result;
8
+ constructor(auth) {
9
+ const isUserServiceKey = auth.key?.startsWith('v1.0-');
10
+ this.cloudflare = new Cloudflare({
11
+ apiEmail: auth.key && !isUserServiceKey ? (auth.email ?? null) : null,
12
+ apiKey: !isUserServiceKey ? auth.key || null : null,
13
+ apiToken: auth.key ? null : auth.token || null,
14
+ userServiceKey: isUserServiceKey ? auth.key : null,
15
+ timeout: 10_000,
16
+ });
25
17
  }
26
- async syncRecords(records, ip) {
27
- const recordIds = await this.getRecordIdsForRecords(records);
28
- const ipToUse = ip ? ip : await IPUtils.getIpv4();
29
- const resultPromises = records.map(async (record) => {
18
+ async syncRecords(records) {
19
+ const existingRecords = await this.getRecordDataForRecords(records);
20
+ const existingByKey = new Map(existingRecords.map((record) => [this.getRecordIdMapKey(record), record]));
21
+ const settled = await Promise.allSettled(records.map(async (record) => {
30
22
  const zoneId = await this.getZoneIdByRecordName(record.name);
31
- const recordId = recordIds.get(this.getRecordIdMapKey(record));
32
- const recordExists = recordId !== undefined;
33
- if (recordExists) {
34
- const currentResult = await this.updateRecord(zoneId, recordId, record, ipToUse);
35
- return currentResult;
23
+ const existing = existingByKey.get(this.getRecordIdMapKey(record));
24
+ if (existing && this.isUnchanged(existing, record)) {
25
+ return existing;
26
+ }
27
+ return existing ? this.updateRecord(zoneId, existing.id, record) : this.createRecord(zoneId, record);
28
+ }));
29
+ const succeeded = [];
30
+ const failed = [];
31
+ for (const [index, result] of settled.entries()) {
32
+ if (result.status === 'fulfilled') {
33
+ succeeded.push(result.value);
36
34
  }
37
- const currentResult = await this.createRecord(zoneId, record, ipToUse);
38
- return currentResult;
35
+ else {
36
+ failed.push({ record: records[index], error: toError(result.reason) });
37
+ }
38
+ }
39
+ if (failed.length > 0) {
40
+ throw new SyncError(succeeded, failed);
41
+ }
42
+ return succeeded;
43
+ }
44
+ async planRecords(records) {
45
+ const existingRecords = await this.getRecordDataForRecords(records);
46
+ const existingByKey = new Map(existingRecords.map((record) => [this.getRecordIdMapKey(record), record]));
47
+ return records.map((record) => {
48
+ const before = existingByKey.get(this.getRecordIdMapKey(record));
49
+ return { record, before, after: record, action: before ? (this.isUnchanged(before, record) ? 'unchanged' : 'update') : 'create' };
39
50
  });
40
- const results = await Promise.all(resultPromises);
41
- return results;
42
51
  }
43
- async removeRecordByNameAndType(recordName, recordType) {
44
- const recordTypeToUse = recordType ? recordType : 'A';
52
+ async removeRecordByNameAndType(recordName, recordType = 'A') {
45
53
  const zoneId = await this.getZoneIdByRecordName(recordName);
46
- const recordId = await this.getRecordIdByNameAndType(recordName, recordTypeToUse);
54
+ const recordId = await this.getRecordIdByNameAndType(recordName, recordType);
47
55
  await this.cloudflare.dns.records.delete(recordId, { zone_id: zoneId });
48
56
  }
49
- async getRecordDataForRecord(record) {
50
- const domain = this.getDomainByRecordName(record.name);
51
- const recordDataForDomain = await this.getRecordsByDomain(domain);
52
- const recordData = recordDataForDomain.find((singleRecordData) => record.name.toLowerCase() === singleRecordData.name.toLowerCase());
53
- return recordData;
54
- }
55
57
  async getRecordDataForRecords(records) {
56
- const domains = this.getDomainsFromRecords(records);
57
- const recordDataPromises = domains.map(async (domain) => {
58
- const recordDataForDomain = await this.getRecordsByDomain(domain);
59
- const recordDataForDomainFilteredByRecords = recordDataForDomain.filter((singleRecordData) => records.some((record) => record.name.toLowerCase() === singleRecordData.name.toLowerCase()));
60
- return recordDataForDomainFilteredByRecords;
61
- });
62
- const recordDataForDomains = await Promise.all(recordDataPromises);
63
- const recordData = [].concat(...recordDataForDomains);
64
- return recordData;
65
- }
66
- async getRecordsByDomains(domains) {
67
- const recordDataPromises = domains.map((domain) => this.getRecordsByDomain(domain));
68
- const recordDataForDomains = await Promise.all(recordDataPromises);
69
- const recordData = {};
70
- recordDataForDomains.forEach((recordDataForDomain, index) => {
71
- recordData[domains[index]] = recordDataForDomain;
72
- });
73
- return recordData;
74
- }
75
- async getRecordsByDomain(domain) {
76
- const zoneId = await this.getZoneIdByDomain(domain);
77
- const allRecords = [];
78
- for await (const recordListResponse of this.cloudflare.dns.records.list({ zone_id: zoneId })) {
79
- allRecords.push(recordListResponse);
80
- }
81
- return allRecords;
82
- }
83
- async createRecord(zoneId, recordData, ip) {
84
- const dnsRecordToCreate = {
85
- ...recordData,
86
- name: recordData.name.toLowerCase(),
87
- content: (recordData.content ? recordData.content : ip),
88
- type: (recordData.type ? recordData.type : 'A'),
89
- ttl: recordData.ttl ? recordData.ttl : 1,
90
- zone_id: zoneId,
58
+ const recordsByDomain = await Promise.all(this.getDomainsFromRecords(records).map((domain) => this.getRecordsByDomain(domain)));
59
+ return recordsByDomain
60
+ .flat()
61
+ .filter((record) => records.some((requestedRecord) => requestedRecord.name.toLowerCase() === record.name.toLowerCase() && (requestedRecord.type === undefined || requestedRecord.type === record.type)));
62
+ }
63
+ async getRecordDataForDomains(domains) {
64
+ const recordsByDomain = await Promise.all(domains.map(async (domain) => [domain, await this.getRecordDataForDomain(domain)]));
65
+ return Object.fromEntries(recordsByDomain);
66
+ }
67
+ getRecordDataForDomain(domain) {
68
+ return this.getRecordsByDomain(domain);
69
+ }
70
+ async createRecord(zoneId, record) {
71
+ const dnsRecord = this.prepareRecord(record);
72
+ const response = await this.cloudflare.dns.records.create({ zone_id: zoneId, ...dnsRecord });
73
+ return response;
74
+ }
75
+ async updateRecord(zoneId, recordId, record) {
76
+ const dnsRecord = this.prepareRecord(record);
77
+ const response = await this.cloudflare.dns.records.update(recordId, { zone_id: zoneId, ...dnsRecord });
78
+ return response;
79
+ }
80
+ prepareRecord(record) {
81
+ const dnsRecord = {
82
+ ...record,
83
+ name: record.name.toLowerCase(),
84
+ content: record.content || '',
85
+ type: record.type || 'A',
86
+ ttl: record.ttl || 1,
91
87
  };
92
- if (!dnsRecordToCreate.content) {
93
- throw Error(`Could not create Record "${dnsRecordToCreate.name}": Content is missing!`);
88
+ if (!dnsRecord.content) {
89
+ throw Error(`Could not sync record "${dnsRecord.name}": content is missing.`);
94
90
  }
95
- if (dnsRecordToCreate.type === 'A') {
96
- if (!dnsRecordToCreate.content.match(ipv4Regex)) {
97
- throw Error(`Could not create Record "${dnsRecordToCreate.name}": '${dnsRecordToCreate.content}' is not a valid ipv4!`);
98
- }
91
+ if (dnsRecord.type === 'A' && isIP(dnsRecord.content) !== 4) {
92
+ throw Error(`Could not sync record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid IPv4 address.`);
99
93
  }
100
- else if (dnsRecordToCreate.type === 'AAAA') {
101
- if (!dnsRecordToCreate.content.match(ipv6Regex)) {
102
- throw Error(`Could not create Record "${dnsRecordToCreate.name}": '${dnsRecordToCreate.content}' is not a valid ipv6!`);
103
- }
94
+ if (dnsRecord.type === 'AAAA' && isIP(dnsRecord.content) !== 6) {
95
+ throw Error(`Could not sync record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid IPv6 address.`);
104
96
  }
105
- else if (dnsRecordToCreate.type === 'CNAME') {
106
- const parsedDomain = parseDomain(fromUrl(dnsRecordToCreate.content));
107
- if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
108
- throw Error(`Could not create Record "${dnsRecordToCreate.name}": '${dnsRecordToCreate.content}' is not a valid domain name!`);
109
- }
97
+ if (dnsRecord.type === 'CNAME' && !this.isValidDomain(dnsRecord.content)) {
98
+ throw Error(`Could not sync record "${dnsRecord.name}": '${dnsRecord.content}' is not a valid domain name.`);
110
99
  }
111
- return this.cloudflare.dns.records.create(dnsRecordToCreate);
112
- }
113
- async updateRecord(zoneId, recordId, recordData, ip) {
114
- const updatedDnsRecord = {
115
- ...recordData,
116
- name: recordData.name.toLowerCase(),
117
- content: (recordData.content ? recordData.content : ip),
118
- type: (recordData.type ? recordData.type : 'A'),
119
- ttl: recordData.ttl ? recordData.ttl : 1,
120
- zone_id: zoneId,
121
- };
122
- if (!updatedDnsRecord.content) {
123
- throw Error(`Could not update Record "${updatedDnsRecord.name}": Content is missing!`);
124
- }
125
- if (updatedDnsRecord.type === 'A') {
126
- if (!updatedDnsRecord.content.match(ipv4Regex)) {
127
- throw Error(`Could not update Record "${updatedDnsRecord.name}": '${updatedDnsRecord.content}' is not a valid ipv4!`);
128
- }
129
- }
130
- else if (updatedDnsRecord.type === 'AAAA') {
131
- if (!updatedDnsRecord.content.match(ipv6Regex)) {
132
- throw Error(`Could not update Record "${updatedDnsRecord.name}": '${updatedDnsRecord.content}' is not a valid ipv6!`);
133
- }
134
- }
135
- else if (updatedDnsRecord.type === 'CNAME') {
136
- const parsedDomain = parseDomain(fromUrl(updatedDnsRecord.content));
137
- if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
138
- throw Error(`Could not update Record "${updatedDnsRecord.name}": '${updatedDnsRecord.content}' is not a valid domain name!`);
139
- }
140
- }
141
- return await this.cloudflare.dns.records.edit(recordId, updatedDnsRecord);
100
+ return dnsRecord;
142
101
  }
143
102
  async updateZoneMap() {
144
- const response = await this.cloudflare.zones.list();
145
- let zones = response.result;
146
- let nextPageExists = response.hasNextPage();
147
- while (nextPageExists) {
148
- const nextPageResponse = await response.getNextPage();
149
- zones = zones.concat(nextPageResponse.result);
150
- nextPageExists = nextPageResponse.hasNextPage();
151
- }
152
- this.zoneMap = new Map();
153
- for (const zone of zones) {
154
- this.zoneMap.set(zone.name, zone.id);
103
+ const zones = new Map();
104
+ for await (const zone of this.cloudflare.zones.list()) {
105
+ zones.set(zone.name.toLowerCase(), zone.id);
155
106
  }
107
+ this.zoneMap = zones;
156
108
  }
157
109
  async getRecordIdByNameAndType(recordName, recordType) {
158
110
  const record = await this.getRecordByNameAndType(recordName, recordType);
@@ -163,54 +115,60 @@ export default class CloudflareClient {
163
115
  return this.getZoneIdByDomain(domain);
164
116
  }
165
117
  async getRecordByNameAndType(recordName, recordType) {
166
- const domain = this.getDomainByRecordName(recordName);
167
- const records = await this.getRecordsByDomain(domain);
168
- const record = records.find((currentRecord) => currentRecord.name.toLowerCase() === recordName.toLowerCase() && currentRecord.type.toLowerCase() === recordType.toLowerCase());
169
- const recordNotFound = record === undefined;
170
- if (recordNotFound) {
118
+ const records = await this.getRecordsByDomain(this.getDomainByRecordName(recordName));
119
+ const record = records.find((entry) => entry.name.toLowerCase() === recordName.toLowerCase() && entry.type.toLowerCase() === recordType.toLowerCase());
120
+ if (!record) {
171
121
  throw new Error(`Record '${recordName}' not found.`);
172
122
  }
173
123
  return record;
174
124
  }
175
- async getRecordIdsForRecords(records) {
176
- const recordIdMap = new Map();
177
- const recordData = await this.getRecordDataForRecords(records);
178
- for (const record of recordData) {
179
- recordIdMap.set(this.getRecordIdMapKey(record), record.id);
180
- }
181
- return recordIdMap;
125
+ isUnchanged(existing, desired) {
126
+ const prepared = this.prepareRecord(desired);
127
+ return (existing.name.toLowerCase() === prepared.name &&
128
+ existing.type === prepared.type &&
129
+ existing.content === prepared.content &&
130
+ existing.ttl === prepared.ttl &&
131
+ (desired.proxied === undefined || existing.proxied === desired.proxied));
182
132
  }
183
133
  getRecordIdMapKey(record) {
184
- const recordName = record.name.toLowerCase();
185
- const recordType = record.type ? record.type.toLowerCase() : 'a';
186
- return `"${recordName}"_"${recordType}"`;
134
+ return `${record.name.toLowerCase()}\u0000${(record.type || 'A').toLowerCase()}`;
135
+ }
136
+ async getRecordsByDomain(domain) {
137
+ const zoneId = await this.getZoneIdByDomain(domain);
138
+ const records = [];
139
+ for await (const record of this.cloudflare.dns.records.list({ zone_id: zoneId, per_page: 5000 })) {
140
+ records.push(record);
141
+ }
142
+ return records;
187
143
  }
188
144
  async getZoneIdByDomain(domain) {
189
- if (this.zoneMap.has(domain)) {
190
- const zoneId = this.zoneMap.get(domain.toLowerCase());
145
+ const normalizedDomain = domain.toLowerCase();
146
+ let zoneId = this.zoneMap.get(normalizedDomain);
147
+ if (zoneId) {
191
148
  return zoneId;
192
149
  }
193
150
  await this.updateZoneMap();
194
- if (!this.zoneMap.has(domain)) {
195
- throw new Error(`Could not find domain '${domain}'. Make sure the domain is set up for your cloudflare account.`);
151
+ zoneId = this.zoneMap.get(normalizedDomain);
152
+ if (!zoneId) {
153
+ throw new Error(`Could not find domain '${normalizedDomain}'. Make sure the domain is set up for your cloudflare account.`);
196
154
  }
197
- const zoneId = this.zoneMap.get(domain.toLowerCase());
198
155
  return zoneId;
199
156
  }
200
157
  getDomainsFromRecords(records) {
201
- const domains = records.map((record) => this.getDomainByRecordName(record.name)).filter((domain, index, domainList) => domainList.indexOf(domain.toLowerCase()) === index);
202
- return domains;
158
+ return [...new Set(records.map((record) => this.getDomainByRecordName(record.name)))];
203
159
  }
204
160
  getDomainByRecordName(recordName) {
205
- const parsedDomain = parseDomain(fromUrl(recordName));
161
+ const parsedDomain = parseDomain(fromUrl(recordName.replace(/^\*\./, '')));
206
162
  if (parsedDomain.type !== ParseResultType.Listed || !parsedDomain.domain) {
207
163
  throw new Error(`Could not parse domain. '${JSON.stringify(recordName)}' is not a valid record name.`);
208
164
  }
209
- let domain = '';
210
- domain += parsedDomain.domain;
211
- for (const tld of parsedDomain.topLevelDomains) {
212
- domain += `.${tld}`;
213
- }
214
- return domain.toLowerCase();
165
+ return [parsedDomain.domain, ...parsedDomain.topLevelDomains].join('.').toLowerCase();
215
166
  }
167
+ isValidDomain(value) {
168
+ const parsedDomain = parseDomain(fromUrl(value));
169
+ return parsedDomain.type === ParseResultType.Listed && Boolean(parsedDomain.domain);
170
+ }
171
+ }
172
+ function toError(value) {
173
+ return value instanceof Error ? value : new Error(String(value));
216
174
  }
@@ -1,5 +1,5 @@
1
1
  import { ScheduledTask } from 'node-cron';
2
2
  export default class Cron {
3
- static createCronJob(cronExpression: string, callback: Function): ScheduledTask;
3
+ static createCronJob(cronExpression: string, callback: () => void | Promise<void>): ScheduledTask;
4
4
  static isValid(cronExpression: string): boolean;
5
5
  }
package/dist/lib/cron.js CHANGED
@@ -5,9 +5,7 @@ export default class Cron {
5
5
  if (cronExpressionIsInvalid) {
6
6
  throw new Error(`'${cronExpression}' is not a valid cron expression.\nHere you can see how cron expressions work: https://cddnss.knaup.pw/cron-expression-syntax`);
7
7
  }
8
- return cron.schedule(cronExpression, () => {
9
- callback();
10
- }, undefined);
8
+ return cron.schedule(cronExpression, callback);
11
9
  }
12
10
  static isValid(cronExpression) {
13
11
  return cron.validate(cronExpression);
@@ -1,9 +1,4 @@
1
1
  export default class IPUtils {
2
- private static readonly ipPollingDelay;
3
- private static ipChangeEventListeners;
4
2
  static getIpv4(): Promise<string>;
5
3
  static getIpv6(): Promise<string>;
6
- static addIpChangeListener(callback: Function): Promise<string>;
7
- static removeIpChangeListener(eventListenerId: string): void;
8
- private static getId;
9
4
  }
@@ -1,7 +1,5 @@
1
1
  import { publicIpv4, publicIpv6 } from 'public-ip';
2
2
  export default class IPUtils {
3
- static ipPollingDelay = 10 * 1000;
4
- static ipChangeEventListeners = new Map();
5
3
  static async getIpv4() {
6
4
  /* c8 ignore start*/
7
5
  try {
@@ -22,28 +20,4 @@ export default class IPUtils {
22
20
  }
23
21
  /* c8 ignore stop*/
24
22
  }
25
- static async addIpChangeListener(callback) {
26
- const eventListenerId = this.getId();
27
- let previousIp = await this.getIpv4();
28
- /* c8 ignore start */
29
- const intervalId = setInterval(async () => {
30
- const currentIp = await this.getIpv4();
31
- const ipMustBeUpdated = currentIp !== previousIp;
32
- if (ipMustBeUpdated) {
33
- previousIp = currentIp;
34
- callback(currentIp);
35
- }
36
- }, this.ipPollingDelay);
37
- /* c8 ignore stop*/
38
- this.ipChangeEventListeners.set(eventListenerId, intervalId);
39
- return eventListenerId;
40
- }
41
- static removeIpChangeListener(eventListenerId) {
42
- const eventListenerIntervalId = this.ipChangeEventListeners.get(eventListenerId);
43
- clearInterval(eventListenerIntervalId);
44
- IPUtils.ipChangeEventListeners.delete(eventListenerId);
45
- }
46
- static getId() {
47
- return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
48
- }
49
23
  }
@@ -0,0 +1,12 @@
1
+ import { DnsRecord, Record } from '../types/index.js';
2
+ export declare class SyncError extends Error {
3
+ readonly succeeded: Array<DnsRecord>;
4
+ readonly failed: Array<{
5
+ record: Record;
6
+ error: Error;
7
+ }>;
8
+ constructor(succeeded: Array<DnsRecord>, failed: Array<{
9
+ record: Record;
10
+ error: Error;
11
+ }>);
12
+ }
@@ -0,0 +1,10 @@
1
+ export class SyncError extends Error {
2
+ succeeded;
3
+ failed;
4
+ constructor(succeeded, failed) {
5
+ super('One or more DNS records could not be synchronized.');
6
+ this.succeeded = succeeded;
7
+ this.failed = failed;
8
+ this.name = 'SyncError';
9
+ }
10
+ }
@@ -0,0 +1,44 @@
1
+ import { DnsRecord, JobOptions, RecordSelection, SyncJob, SyncOptions, WatchOptions } from '../types/index.js';
2
+ export type SyncJobOperations = {
3
+ sync(records: RecordSelection | undefined, options: SyncOptions): Promise<Array<DnsRecord>>;
4
+ ip(family: 4 | 6): Promise<string>;
5
+ };
6
+ declare abstract class BaseSyncJob implements SyncJob {
7
+ protected readonly operations: SyncJobOperations;
8
+ protected readonly options: JobOptions;
9
+ private inFlight?;
10
+ private callingCallback;
11
+ constructor(operations: SyncJobOperations, options: JobOptions);
12
+ run(): Promise<Array<DnsRecord>>;
13
+ abstract start(): Promise<void>;
14
+ abstract stop(): Promise<void>;
15
+ protected runInBackground(options?: SyncOptions): void;
16
+ protected runWith(options: SyncOptions): Promise<Array<DnsRecord>>;
17
+ protected settle(): Promise<void>;
18
+ protected reportError(error: Error): Promise<void>;
19
+ private perform;
20
+ }
21
+ export declare class ScheduledSyncJob extends BaseSyncJob {
22
+ private readonly task;
23
+ private active;
24
+ constructor(operations: SyncJobOperations, expression: string, options: JobOptions);
25
+ start(): Promise<void>;
26
+ stop(): Promise<void>;
27
+ }
28
+ export declare class WatchSyncJob extends BaseSyncJob {
29
+ private readonly watchOptions;
30
+ private readonly families;
31
+ private timer?;
32
+ private active;
33
+ private starting?;
34
+ private checking?;
35
+ private previousAddresses;
36
+ constructor(operations: SyncJobOperations, watchOptions: WatchOptions, families: Array<4 | 6>);
37
+ start(): Promise<void>;
38
+ stop(): Promise<void>;
39
+ private startWatcher;
40
+ private checkInBackground;
41
+ private checkForChanges;
42
+ private currentAddresses;
43
+ }
44
+ export {};
@@ -0,0 +1,190 @@
1
+ import Cron from './cron.js';
2
+ const defaultWatchIntervalMs = 10_000;
3
+ class BaseSyncJob {
4
+ operations;
5
+ options;
6
+ inFlight;
7
+ callingCallback = false;
8
+ constructor(operations, options) {
9
+ this.operations = operations;
10
+ this.options = options;
11
+ }
12
+ run() {
13
+ return this.runWith(this.options);
14
+ }
15
+ runInBackground(options = this.options) {
16
+ void this.runWith(options).catch(async (error) => this.reportError(toError(error)));
17
+ }
18
+ runWith(options) {
19
+ if (this.inFlight) {
20
+ return this.inFlight;
21
+ }
22
+ const run = this.perform(options);
23
+ this.inFlight = run;
24
+ void run
25
+ .finally(() => {
26
+ if (this.inFlight === run) {
27
+ this.inFlight = undefined;
28
+ }
29
+ })
30
+ .catch(() => undefined);
31
+ return run;
32
+ }
33
+ async settle() {
34
+ if (!this.callingCallback) {
35
+ await this.inFlight?.catch(() => undefined);
36
+ }
37
+ }
38
+ async reportError(error) {
39
+ try {
40
+ if (this.options.onError) {
41
+ await this.options.onError(error);
42
+ }
43
+ else {
44
+ console.error(error);
45
+ }
46
+ }
47
+ catch (callbackError) {
48
+ console.error(toError(callbackError));
49
+ }
50
+ }
51
+ async perform(options) {
52
+ const result = await this.operations.sync(this.options.records, options);
53
+ this.callingCallback = true;
54
+ try {
55
+ await this.options.onSync?.(result);
56
+ }
57
+ finally {
58
+ this.callingCallback = false;
59
+ }
60
+ return result;
61
+ }
62
+ }
63
+ export class ScheduledSyncJob extends BaseSyncJob {
64
+ task;
65
+ active = true;
66
+ constructor(operations, expression, options) {
67
+ super(operations, options);
68
+ this.task = Cron.createCronJob(expression, () => {
69
+ if (this.active) {
70
+ this.runInBackground();
71
+ }
72
+ });
73
+ }
74
+ async start() {
75
+ if (!this.active) {
76
+ this.active = true;
77
+ await this.task.start();
78
+ }
79
+ }
80
+ async stop() {
81
+ this.active = false;
82
+ await this.task.stop();
83
+ await this.settle();
84
+ }
85
+ }
86
+ export class WatchSyncJob extends BaseSyncJob {
87
+ watchOptions;
88
+ families;
89
+ timer;
90
+ active = false;
91
+ starting;
92
+ checking;
93
+ previousAddresses = new Map();
94
+ constructor(operations, watchOptions, families) {
95
+ super(operations, watchOptions);
96
+ this.watchOptions = watchOptions;
97
+ this.families = families;
98
+ }
99
+ start() {
100
+ if (this.starting) {
101
+ return this.starting;
102
+ }
103
+ if (this.active) {
104
+ return Promise.resolve();
105
+ }
106
+ this.active = true;
107
+ const start = this.startWatcher();
108
+ this.starting = start;
109
+ void start.finally(() => {
110
+ if (this.starting === start) {
111
+ this.starting = undefined;
112
+ }
113
+ });
114
+ return start;
115
+ }
116
+ async stop() {
117
+ this.active = false;
118
+ if (this.timer) {
119
+ clearInterval(this.timer);
120
+ this.timer = undefined;
121
+ }
122
+ await this.checking?.catch(() => undefined);
123
+ await this.settle();
124
+ }
125
+ async startWatcher() {
126
+ const addresses = await this.currentAddresses();
127
+ if (!this.active) {
128
+ return;
129
+ }
130
+ await this.runWith({ ...this.options, ...addressesToOptions(addresses) });
131
+ if (!this.active) {
132
+ return;
133
+ }
134
+ this.previousAddresses = addresses;
135
+ if (this.families.length > 0) {
136
+ this.timer = setInterval(() => {
137
+ this.checkInBackground();
138
+ }, this.watchOptions.intervalMs ?? defaultWatchIntervalMs);
139
+ }
140
+ }
141
+ checkInBackground() {
142
+ if (!this.active || this.checking) {
143
+ return;
144
+ }
145
+ const check = this.checkForChanges();
146
+ this.checking = check;
147
+ void check
148
+ .catch(async (error) => this.reportError(toError(error)))
149
+ .finally(() => {
150
+ if (this.checking === check) {
151
+ this.checking = undefined;
152
+ }
153
+ });
154
+ }
155
+ async checkForChanges() {
156
+ const addresses = await this.currentAddresses();
157
+ if (!this.active || !addressesChanged(this.previousAddresses, addresses)) {
158
+ return;
159
+ }
160
+ await this.runWith({ ...this.options, ...addressesToOptions(addresses) });
161
+ if (this.active) {
162
+ this.previousAddresses = addresses;
163
+ }
164
+ }
165
+ async currentAddresses() {
166
+ const addresses = new Map();
167
+ await Promise.all(this.families.map(async (family) => {
168
+ addresses.set(family, await this.operations.ip(family));
169
+ }));
170
+ return addresses;
171
+ }
172
+ }
173
+ function addressesToOptions(addresses) {
174
+ const options = {};
175
+ const ipv4 = addresses.get(4);
176
+ const ipv6 = addresses.get(6);
177
+ if (ipv4) {
178
+ options.ipv4 = ipv4;
179
+ }
180
+ if (ipv6) {
181
+ options.ipv6 = ipv6;
182
+ }
183
+ return options;
184
+ }
185
+ function addressesChanged(previous, current) {
186
+ return previous.size !== current.size || [...current].some(([family, address]) => previous.get(family) !== address);
187
+ }
188
+ function toError(value) {
189
+ return value instanceof Error ? value : new Error(String(value));
190
+ }