Skip to content

Commit 9d8509e

Browse files
authored
C3: Refactor dns polling to improve experience for WARP users (#3729)
* C3: Refactor dns resolution to improve reliability of DNS polling in summary step
1 parent 94fed9d commit 9d8509e

File tree

6 files changed

+133
-43
lines changed

6 files changed

+133
-43
lines changed

.changeset/hip-lobsters-sniff.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"create-cloudflare": patch
3+
---
4+
5+
Improve experience for WARP users by improving the reliability of the polling logic that waits for newly created apps to become available.

package-lock.json

+36-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/create-cloudflare/dns2.d.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type {DnsAnswer as _DnsAnswer, DnsResponse as _DnsResponse} from 'dns2'
2+
3+
declare module 'dns2' {
4+
export interface DnsAnswer extends _DnsAnswer {
5+
ns: string;
6+
}
7+
8+
export interface DnsResponse extends _DnsResponse {
9+
authorities: DnsAnswer[];
10+
}
11+
}

packages/create-cloudflare/e2e-tests/pages.test.ts

-20
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { existsSync, mkdtempSync, realpathSync, rmSync } from "fs";
22
import crypto from "node:crypto";
33
import { tmpdir } from "os";
44
import { join } from "path";
5-
import spawn from "cross-spawn";
65
import { FrameworkMap } from "frameworks/index";
76
import { readJSON } from "helpers/files";
87
import { fetch } from "undici";
@@ -39,29 +38,10 @@ describe(`E2E: Web frameworks`, () => {
3938
afterEach((ctx) => {
4039
const framework = ctx.meta.name;
4140
const projectPath = getProjectPath(framework);
42-
const projectName = getProjectName(framework);
4341

4442
if (existsSync(projectPath)) {
4543
rmSync(projectPath, { recursive: true });
4644
}
47-
48-
try {
49-
const { output } = spawn.sync("npx", [
50-
"wrangler",
51-
"pages",
52-
"project",
53-
"delete",
54-
"-y",
55-
projectName,
56-
]);
57-
58-
if (!output.toString().includes(`Successfully deleted ${projectName}`)) {
59-
console.error(output.toString());
60-
}
61-
} catch (error) {
62-
console.error(`Failed to cleanup project: ${projectName}`);
63-
console.error(error);
64-
}
6545
});
6646

6747
const runCli = async (

packages/create-cloudflare/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@cloudflare/workers-types": "^4.20230419.0",
4545
"@types/command-exists": "^1.2.0",
4646
"@types/cross-spawn": "^6.0.2",
47+
"@types/dns2": "^2.0.3",
4748
"@types/esprima": "^4.0.3",
4849
"@types/node": "^18.15.3",
4950
"@types/which-pm-runs": "^1.0.0",
@@ -53,6 +54,7 @@
5354
"chalk": "^5.2.0",
5455
"command-exists": "^1.2.9",
5556
"cross-spawn": "^7.0.3",
57+
"dns2": "^2.1.0",
5658
"esbuild": "^0.17.12",
5759
"execa": "^7.1.1",
5860
"haikunator": "^2.1.2",

packages/create-cloudflare/src/helpers/poll.ts

+79-21
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,63 @@
1-
import { Resolver } from "node:dns/promises";
1+
import dns2 from "dns2";
22
import { request } from "undici";
33
import { blue, brandColor, dim } from "./colors";
44
import { spinner } from "./interactive";
5+
import type { DnsAnswer, DnsResponse } from "dns2";
56

67
const TIMEOUT = 1000 * 60 * 5;
78
const POLL_INTERVAL = 1000;
89

10+
/*
11+
A helper to wait until the newly deployed domain is available.
12+
13+
We do this by first polling DNS until the new domain is resolvable, and then polling
14+
via HTTP until we get a successful response.
15+
16+
Note that when polling DNS we make queries against specific nameservers to avoid negative
17+
caching. Similarly, we poll via HTTP using the 'no-cache' header for the same reason.
18+
*/
919
export const poll = async (url: string): Promise<boolean> => {
1020
const start = Date.now();
1121
const domain = new URL(url).host;
1222
const s = spinner();
1323

1424
s.start("Waiting for DNS to propagate");
25+
26+
// Start out by sleeping for 10 seconds since it's unlikely DNS changes will
27+
// have propogated before then
28+
await sleep(10 * 1000);
29+
30+
await pollDns(domain, start, s);
31+
if (await pollHttp(url, start, s)) return true;
32+
33+
s.stop(
34+
`${brandColor(
35+
"timed out"
36+
)} while waiting for ${url} - try accessing it in a few minutes.`
37+
);
38+
return false;
39+
};
40+
41+
const pollDns = async (
42+
domain: string,
43+
start: number,
44+
s: ReturnType<typeof spinner>
45+
) => {
1546
while (Date.now() - start < TIMEOUT) {
1647
s.update(`Waiting for DNS to propagate (${secondsSince(start)}s)`);
17-
if (await dnsLookup(domain)) {
48+
if (await isDomainResolvable(domain)) {
1849
s.stop(`${brandColor("DNS propagation")} ${dim("complete")}.`);
19-
break;
50+
return;
2051
}
2152
await sleep(POLL_INTERVAL);
2253
}
54+
};
2355

56+
const pollHttp = async (
57+
url: string,
58+
start: number,
59+
s: ReturnType<typeof spinner>
60+
) => {
2461
s.start("Waiting for deployment to become available");
2562
while (Date.now() - start < TIMEOUT) {
2663
s.update(
@@ -45,29 +82,50 @@ export const poll = async (url: string): Promise<boolean> => {
4582
}
4683
await sleep(POLL_INTERVAL);
4784
}
48-
49-
s.stop(
50-
`${brandColor(
51-
"timed out"
52-
)} while waiting for ${url} - try accessing it in a few minutes.`
53-
);
54-
return false;
5585
};
5686

57-
async function dnsLookup(domain: string): Promise<boolean> {
87+
// Determines if the domain is resolvable via DNS. Until this condition is true,
88+
// any HTTP requests will result in an NXDOMAIN error.
89+
export const isDomainResolvable = async (domain: string) => {
5890
try {
59-
const resolver = new Resolver({ timeout: TIMEOUT, tries: 1 });
60-
resolver.setServers([
61-
"1.1.1.1",
62-
"1.0.0.1",
63-
"2606:4700:4700::1111",
64-
"2606:4700:4700::1001",
65-
]);
66-
return (await resolver.resolve4(domain)).length > 0;
67-
} catch (e) {
91+
const nameServers = await lookupSubdomainNameservers(domain);
92+
93+
// If the subdomain nameservers aren't resolvable yet, keep polling
94+
if (nameServers.length === 0) return false;
95+
96+
// Once they are resolvable, query these nameservers for the domain's 'A' record
97+
const dns = new dns2({ nameServers });
98+
const res = await dns.resolve(domain, "A");
99+
return res.answers.length > 0;
100+
} catch (error) {
68101
return false;
69102
}
70-
}
103+
};
104+
105+
// Looks up the nameservers that are responsible for this particular domain
106+
export const lookupSubdomainNameservers = async (domain: string) => {
107+
const nameServers = await lookupDomainLevelNameservers(domain);
108+
const dns = new dns2({ nameServers });
109+
const res = (await dns.resolve(domain, "NS")) as DnsResponse;
110+
111+
return (
112+
res.authorities
113+
// Filter out non-authoritative authorities (ones that don't have an 'ns' property)
114+
.filter((r) => Boolean(r.ns))
115+
// Return only the hostnames of the authoritative servers
116+
.map((r) => r.ns)
117+
);
118+
};
119+
120+
// Looks up the nameservers responsible for handling `pages.dev` or `workers.dev` domains
121+
export const lookupDomainLevelNameservers = async (domain: string) => {
122+
// Get the last 2 parts of the domain (ie. `pages.dev` or `workers.dev`)
123+
const baseDomain = domain.split(".").slice(-2).join(".");
124+
125+
const dns = new dns2({});
126+
const nameservers = await dns.resolve(baseDomain, "NS");
127+
return (nameservers.answers as DnsAnswer[]).map((n) => n.ns);
128+
};
71129

72130
async function sleep(ms: number) {
73131
return new Promise((res) => setTimeout(res, ms));

0 commit comments

Comments
 (0)