Skip to content

Commit

Permalink
feat: poll dns and http to know when deploy is ready
Browse files Browse the repository at this point in the history
  • Loading branch information
atinux committed Mar 22, 2024
1 parent 4c30180 commit 4ae0351
Show file tree
Hide file tree
Showing 11 changed files with 138 additions and 14 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"blake3-wasm": "^2.1.5",
"citty": "^0.1.6",
"consola": "^3.2.3",
"dns2": "^2.1.0",
"dotenv": "^16.4.5",
"execa": "^8.0.1",
"get-port-please": "^3.1.2",
Expand All @@ -35,6 +36,7 @@
"mime": "^4.0.1",
"ofetch": "^1.3.3",
"open": "^10.0.4",
"ora": "^8.0.1",
"pathe": "^1.1.2",
"pretty-bytes": "^6.1.1",
"rc9": "^2.1.1",
Expand Down
19 changes: 13 additions & 6 deletions src/commands/deploy.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ora from 'ora'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { isCancel, confirm } from '@clack/prompts'
Expand All @@ -10,7 +11,7 @@ import { execa } from 'execa'
import { existsSync } from 'fs'
import mime from 'mime'
import prettyBytes from 'pretty-bytes'
import { $api, fetchUser, selectTeam, selectProject, projectPath, withTilde, fetchProject, linkProject, hashFile, gitInfo, getPackageJson, MAX_ASSET_SIZE } from '../utils/index.mjs'
import { $api, fetchUser, selectTeam, selectProject, projectPath, withTilde, fetchProject, linkProject, hashFile, gitInfo, getPackageJson, pollDns, pollHttp, MAX_ASSET_SIZE } from '../utils/index.mjs'
import login from './login.mjs'

export default defineCommand({
Expand Down Expand Up @@ -71,8 +72,8 @@ export default defineCommand({
// Default to main branch
git.branch = git.branch || 'main'
const deployEnv = git.branch === linkedProject.productionBranch ? 'production' : 'preview'
consola.success(`Connected to \`${linkedProject.teamSlug}\` team.`)
consola.info(`Preparing to deploy \`${linkedProject.slug}\` to \`${deployEnv}\`.`)
const deployEnvColored = deployEnv === 'production' ? colors.green(deployEnv) : colors.yellow(deployEnv)
consola.success(`Connected to ${colors.blue(linkedProject.teamSlug)} team.`)

if (args.build) {
const pkg = await getPackageJson()
Expand Down Expand Up @@ -103,7 +104,7 @@ export default defineCommand({

const distDir = join(process.cwd(), 'dist')
if (!existsSync(distDir)) {
consola.error(`\`${withTilde(distDir)}\` directory not found, please make sure that you have built your project.`)
consola.error(`${colors.cyan(withTilde(distDir))} directory not found, please make sure that you have built your project.`)
process.exit(1)
}
const srcStorage = createStorage({
Expand Down Expand Up @@ -132,14 +133,20 @@ export default defineCommand({
}))
// TODO: make a tar with nanotar by the amazing Pooya Parsa (@pi0)

consola.start(`Deploying \`${linkedProject.slug}\` to \`${deployEnv}\`...`)
const spinner = ora(`Deploying ${colors.blue(linkedProject.slug)} to ${deployEnvColored}...`).start()
const deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/deploy`, {
method: 'POST',
body: {
git,
files
}
})
consola.success(`Project deployed on \`${deployment.url}\``)
spinner.stop()
// Check DNS & ready url for first deployment
if (deployment.isFirstDeploy) {
await pollDns(deployment.url)
}
await pollHttp(deployment.primaryUrl || deployment.url)
process.exit(0)
},
})
4 changes: 2 additions & 2 deletions src/commands/link.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineCommand({
}
let project = await fetchProject()
if (project) {
consola.warn(`This directory is already linked to the \`${project.slug}\` project.`)
consola.warn(`This directory is already linked to the ${colors.blue(project.slug)} project.`)

const linkAnyway = await confirm({
message: `Do you want to link ${colors.blue(projectPath())} to another project?`,
Expand All @@ -45,6 +45,6 @@ export default defineCommand({

await linkProject(project)

consola.success(`Project \`${project.slug}\` linked.`)
consola.success(`Project ${colors.blue(project.slug)} linked.`)
},
})
3 changes: 2 additions & 1 deletion src/commands/login.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { hostname } from 'os'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { defineCommand } from 'citty'
import { isHeadless, fetchUser, updateUserConfig, $api, NUXT_HUB_URL } from '../utils/index.mjs'
import { createApp, eventHandler, toNodeListener, getQuery, sendRedirect } from 'h3'
Expand All @@ -19,7 +20,7 @@ export default defineCommand({
}
const user = await fetchUser()
if (user) {
return consola.info(`Already logged in as \`${user.name}\``)
return consola.info(`Already logged in as ${colors.blue(user.name)}`)
}
// Create server for OAuth flow
let listener
Expand Down
3 changes: 2 additions & 1 deletion src/commands/open.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ export default defineCommand({
// Guess the env based on the branch
env = (git.branch === project.productionBranch) ? 'production' : 'preview'
}
const envColored = env === 'production' ? colors.green(env) : colors.yellow(env)
const url = (env === 'production' ? project.url : project.previewUrl)

if (!url) {
consola.info(`Project \`${project.slug}\` does not have a \`${env}\` URL, please run \`nuxthub deploy --${env}\`.`)
consola.info(`Project ${colors.blue(project.slug)} does not have a ${envColored} URL, please run \`nuxthub deploy --${env}\`.`)
return
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/unlink.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ export default defineCommand({

await unlinkProject()

consola.success(`Project \`${project.slug}\` unlinked.`)
consola.success(`Project ${colors.blue(project.slug)} unlinked.`)
},
})
3 changes: 2 additions & 1 deletion src/commands/whoami.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { defineCommand } from 'citty'
import { fetchUser } from '../utils/index.mjs'

Expand All @@ -13,6 +14,6 @@ export default defineCommand({
consola.info('Not currently logged in.')
return
}
consola.info(`Logged in as \`${user.name}\``)
consola.info(`Logged in as ${colors.blue(user.name)}`)
},
})
4 changes: 2 additions & 2 deletions src/utils/data.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { isCancel, select, text } from '@clack/prompts'
import { joinURL } from 'ufo'
import { ofetch } from 'ofetch'
Expand Down Expand Up @@ -93,7 +94,6 @@ export async function selectProject(team) {
placeholder: defaultProductionBranch
})
if (isCancel(productionBranch)) return null
consola.start(`Creating project \`${projectName}\` on NuxtHub...`)
project = await $api(`/teams/${team.slug}/projects`, {
method: 'POST',
body: {
Expand All @@ -110,7 +110,7 @@ export async function selectProject(team) {
}
throw err
})
consola.success(`Project \`${project.slug}\` created`)
consola.success(`Project ${colors.blue(project.slug)} created`)
} else {
project = projects.find((project) => project.id === projectId)
}
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './config.mjs'
export * from './data.mjs'
export * from './deploy.mjs'
export * from './git.mjs'
export * from './poll.mjs'
111 changes: 111 additions & 0 deletions src/utils/poll.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { consola } from 'consola'
import { colors } from 'consola/utils'
import dns2 from 'dns2'
import { ofetch } from 'ofetch'
import ora from 'ora'

const TIMEOUT = 1000 * 60 * 5
const POLL_INTERVAL = 1000
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

// Inspired by https://github.com/cloudflare/workers-sdk/blob/b58ed9f2e7236e0e88f936bbf946f310ca3cf37f/packages/create-cloudflare/src/helpers/poll.ts#L20

export async function pollDns (url) {
const start = Date.now()
const s = ora().start('Waiting for DNS to propagate')
const domain = new URL(url).host

// Start out by sleeping for 10 seconds since it's unlikely DNS changes will
await sleep(10 * 1000)

while (Date.now() - start < TIMEOUT) {
s.text =`Waiting for DNS to propagate (${secondsSince(start)}s)`
if (await isDomainResolvable(domain)) {
s.stop()
consola.success(`DNS propagation ${colors.cyan('complete')}.`)
return
}
await sleep(POLL_INTERVAL)
}
s.stop()
consola.warn(`Timed out while waiting for ${colors.cyan(url)} - try accessing it in a few minutes.`)
}

export async function pollHttp (url) {
const start = Date.now()
const s = ora('Waiting for deployment to become available').start()

while (Date.now() - start < TIMEOUT) {
s.text = `Waiting for deployment to become available ${secondsSince(start) > 3 ? `(${secondsSince(start)}s)` : ''}`
try {
const response = await ofetch.raw(url, {
reset: true,
headers: { 'Cache-Control': 'no-cache' },
})
if (response.status < 300) {
s.stop()
console.success(`Deployment is ready at ${colors.cyan(url)}`)
return true
}
} catch (e) {
if (e.response?.status === 401) {
s.stop()
consola.success(`Deployment is ready at ${colors.cyan(url)}`)
return true
}
if (e.response && e.response.status !== 404) {
s.stop()
consola.error(e)
process.exit(1)
}
}
await sleep(POLL_INTERVAL)
}
}

// Determines if the domain is resolvable via DNS. Until this condition is true,
// any HTTP requests will result in an NXDOMAIN error.
export const isDomainResolvable = async (domain) => {
try {
const nameServers = await lookupSubdomainNameservers(domain)

// If the subdomain nameservers aren't resolvable yet, keep polling
if (nameServers.length === 0) return false

// Once they are resolvable, query these nameservers for the domain's 'A' record
const dns = new dns2({ nameServers })
const res = await dns.resolve(domain, 'A')
return res.answers.length > 0
} catch (error) {
return false
}
}

// Looks up the nameservers that are responsible for this particular domain
export const lookupSubdomainNameservers = async (domain) => {
const nameServers = await lookupDomainLevelNameservers(domain)
const dns = new dns2({ nameServers })
const res = await dns.resolve(domain, 'NS')

return (
res.authorities
// Filter out non-authoritative authorities (ones that don't have an 'ns' property)
.filter((r) => Boolean(r.ns))
// Return only the hostnames of the authoritative servers
.map((r) => r.ns)
)
}

// Looks up the nameservers responsible for handling `pages.dev` or `workers.dev` domains
export const lookupDomainLevelNameservers = async (domain) => {
// Get the last 2 parts of the domain (ie. `pages.dev` or `workers.dev`)
const baseDomain = domain.split('.').slice(-2).join('.')

const dns = new dns2({})
const nameservers = await dns.resolve(baseDomain, 'NS')
return (nameservers.answers).map((n) => n.ns)
}

function secondsSince(start) {
return Math.round((Date.now() - start) / 1000)
}

0 comments on commit 4ae0351

Please sign in to comment.