// Native const {homedir} = require('os') const {resolve: resolvePath, join: joinPaths} = require('path') const EventEmitter = require('events') // Packages const bytes = require('bytes') const chalk = require('chalk') const resumer = require('resumer') const retry = require('async-retry').default const splitArray = require('split-array') const {parse: parseIni} = require('ini') const {readFile, stat, lstat} = require('fs-promise') // Ours const {npm: getNpmFiles, docker: getDockerFiles} = require('./get-files') const ua = require('./ua') const hash = require('./hash') const Agent = require('./agent') const readMetaData = require('./read-metadata') // how many concurrent HTTP/2 stream uploads const MAX_CONCURRENT = 10 // check if running windows const IS_WIN = /^win/.test(process.platform) const SEP = IS_WIN ? '\\' : '/' module.exports = class Now extends EventEmitter { constructor(url, token, {forceNew = false, debug = false}) { super() this._token = token this._debug = debug this._forceNew = forceNew this._agent = new Agent(url, {debug}) this._onRetry = this._onRetry.bind(this) } async create(path, { wantsPublic, quiet = false, env = {}, followSymlinks = true, forceNew = false, forceSync = false, forwardNpm = false, deploymentType = 'npm', deploymentName, isStatic = false }) { this._path = path this._static = isStatic let files const {pkg, name, description, nowConfig} = await readMetaData(path, { deploymentType, deploymentName, quiet, isStatic }) if (this._debug) { console.time('> [debug] Getting files') } if (deploymentType === 'npm') { files = await getNpmFiles(path, pkg, nowConfig, {debug: this._debug}) } else { files = await getDockerFiles(path, nowConfig, {debug: this._debug}) } if (this._debug) { console.timeEnd('> [debug] Getting files') } forwardNpm = forwardNpm || nowConfig.forwardNpm // Read .npmrc let npmrc = {} let authToken if (deploymentType === 'npm' && forwardNpm) { try { npmrc = await readFile(resolvePath(path, '.npmrc'), 'utf8') npmrc = parseIni(npmrc) authToken = npmrc['//registry.npmjs.org/:_authToken'] } catch (err) { // Do nothing } if (!authToken) { try { npmrc = await readFile(resolvePath(homedir(), '.npmrc'), 'utf8') npmrc = parseIni(npmrc) authToken = npmrc['//registry.npmjs.org/:_authToken'] } catch (err) { // Do nothing } } } if (this._debug) { console.time('> [debug] Computing hashes') } const pkgDetails = {} pkgDetails.name = name Object.assign(pkgDetails, pkg) const hashes = await hash(files, isStatic, pkgDetails) if (this._debug) { console.timeEnd('> [debug] Computing hashes') } this._files = hashes const engines = nowConfig.engines || pkg.engines const deployment = await this.retry(async bail => { if (this._debug) { console.time('> [debug] /now/create') } // Flatten the array to contain files to sync where each nested input // array has a group of files with the same sha but different path const files = await Promise.all(Array.prototype.concat.apply([], await Promise.all((Array.from(this._files)).map(async ([sha, {data, names}]) => { const statFn = followSymlinks ? stat : lstat return await names.map(async name => { let mode const getMode = async () => { const st = await statFn(name) return st.mode } if (this._static) { if (toRelative(name, this._path) === 'package.json') { mode = 33261 } else { mode = await getMode() name = this.pathInsideContent(name) } } else { mode = await getMode() } return { sha, size: data.length, file: toRelative(name, this._path), mode } }) })))) const res = await this._fetch('/now/create', { method: 'POST', body: { env, public: wantsPublic, forceNew, forceSync, name, description, deploymentType, registryAuthToken: authToken, files, engines } }) if (this._debug) { console.timeEnd('> [debug] /now/create') } // no retry on 4xx let body try { body = await res.json() } catch (err) { throw new Error('Unexpected response') } if (res.status === 429) { return bail(responseError(res)) } else if (res.status >= 400 && res.status < 500) { const err = new Error(body.error.message) err.userError = true return bail(err) } else if (res.status !== 200) { throw new Error(body.error.message) } return body }) // we report about files whose sizes are too big let missingVersion = false if (deployment.warnings) { let sizeExceeded = 0 deployment.warnings.forEach(warning => { if (warning.reason === 'size_limit_exceeded') { const {sha, limit} = warning const n = hashes.get(sha).names.pop() console.error('> \u001b[31mWarning!\u001b[39m Skipping file %s (size exceeded %s)', n, bytes(limit) ) hashes.get(sha).names.unshift(n) // move name (hack, if duplicate matches we report them in order) sizeExceeded++ } else if (warning.reason === 'node_version_not_found') { const {wanted, used} = warning console.error('> \u001b[31mWarning!\u001b[39m Requested node version %s is not available', wanted, used ) missingVersion = true } }) if (sizeExceeded) { console.error(`> \u001b[31mWarning!\u001b[39m ${sizeExceeded} of the files ` + 'exceeded the limit for your plan.\n' + `> See ${chalk.underline('https://zeit.co/account')} to upgrade.`) } } if (!quiet && deploymentType === 'npm' && deployment.nodeVersion) { if (engines && engines.node) { if (missingVersion) { console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (default)`) } else { console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (requested: ${chalk.dim(`\`${engines.node}\``)})`) } } else { console.log(`> Using Node.js ${chalk.bold(deployment.nodeVersion)} (default)`) } } this._id = deployment.deploymentId this._host = deployment.url this._missing = deployment.missing || [] return this._url } pathInsideContent(position) { const relativePath = toRelative(position, this._path) const contentDir = joinPaths(this._path, 'content') const newPath = joinPaths(contentDir, relativePath) return newPath } upload() { const parts = splitArray(this._missing, MAX_CONCURRENT) if (this._debug) { console.log('> [debug] Will upload ' + `${this._missing.length} files in ${parts.length} ` + `steps of ${MAX_CONCURRENT} uploads.`) } const uploadChunk = () => { Promise.all(parts.shift().map(sha => retry(async (bail, attempt) => { const file = this._files.get(sha) const {data, names} = file if (this._debug) { console.time(`> [debug] /sync #${attempt} ${names.join(' ')}`) } const stream = resumer().queue(data).end() const res = await this._fetch('/now/sync', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': data.length, 'x-now-deployment-id': this._id, 'x-now-sha': sha, 'x-now-file': names.map(name => { if (this._static) { name = this.pathInsideContent(name) } return toRelative(encodeURIComponent(name), this._path) }).join(','), 'x-now-size': data.length }, body: stream }) if (this._debug) { console.timeEnd(`> [debug] /sync #${attempt} ${names.join(' ')}`) } // no retry on 4xx if (res.status !== 200 && (res.status >= 400 || res.status < 500)) { if (this._debug) { console.log('> [debug] bailing on creating due to %s', res.status) } return bail(responseError(res)) } this.emit('upload', file) }, {retries: 3, randomize: true, onRetry: this._onRetry}))) .then(() => parts.length ? uploadChunk() : this.emit('complete')) .catch(err => this.emit('error', err)) } uploadChunk() } async listSecrets() { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] #${attempt} GET /secrets`) } const res = await this._fetch('/now/secrets') if (this._debug) { console.timeEnd(`> [debug] #${attempt} GET /secrets`) } const body = await res.json() return body.secrets }) } async list(app) { const query = app ? `?app=${encodeURIComponent(app)}` : '' const {deployments} = await this.retry(async bail => { if (this._debug) { console.time('> [debug] /list') } const res = await this._fetch('/now/list' + query) if (this._debug) { console.timeEnd('> [debug] /list') } // no retry on 4xx if (res.status >= 400 && res.status < 500) { if (this._debug) { console.log('> [debug] bailing on listing due to %s', res.status) } return bail(responseError(res)) } if (res.status !== 200) { throw new Error('Fetching deployment list failed') } return res.json() }, {retries: 3, minTimeout: 2500, onRetry: this._onRetry}) return deployments } async listAliases(deploymentId) { return this.retry(async () => { const res = await this._fetch(deploymentId ? `/now/deployments/${deploymentId}/aliases` : '/now/aliases') const body = await res.json() return body.aliases }) } async last(app) { const deployments = await this.list(app) const last = deployments.sort((a, b) => { return b.created - a.created }).shift() if (!last) { const e = Error(`No deployments found for "${app}"`) e.userError = true throw e } return last } async listDomains() { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] #${attempt} GET /domains`) } const res = await this._fetch('/domains') if (this._debug) { console.timeEnd(`> [debug] #${attempt} GET /domains`) } const body = await res.json() return body.domains }) } async getDomain(domain) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] #${attempt} GET /domains/${domain}`) } const res = await this._fetch(`/domains/${domain}`) if (this._debug) { console.timeEnd(`> [debug] #${attempt} GET /domains/${domain}`) } return await res.json() }) } getNameservers(domain) { return new Promise((resolve, reject) => { let fallback = false this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`) } const res = await this._fetch(`/whois-ns${fallback ? '-fallback' : ''}?domain=${encodeURIComponent(domain)}`) if (this._debug) { console.timeEnd(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`) } const body = await res.json() if (res.status === 200) { if ((!body.nameservers || body.nameservers.length === 0) && !fallback) { // if the nameservers are `null` it's likely // that our whois service failed to parse it fallback = true throw new Error('Invalid whois response') } return body } if (attempt > 1) { fallback = true } throw new Error(`Whois error (${res.status}): ${body.error.message}`) }).then(body => { body.nameservers = body.nameservers.filter(ns => { // temporary hack: // sometimes we get a response that looks like: // ['ns', 'ns', '', ''] // so we filter the empty ones return ns.length }) resolve(body) }).catch(err => { reject(err) }) }) } // _ensures_ the domain is setup (idempotent) setupDomain(name, {isExternal} = {}) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] #${attempt} POST /domains`) } const res = await this._fetch('/domains', { method: 'POST', body: {name, isExternal: Boolean(isExternal)} }) if (this._debug) { console.timeEnd(`> [debug] #${attempt} POST /domains`) } const body = await res.json() if (res.status === 403) { const code = body.error.code let err if (code === 'custom_domain_needs_upgrade') { err = new Error(`Custom domains are only enabled for premium accounts. Please upgrade at ${chalk.underline('https://zeit.co/account')}.`) } else { err = new Error(`Not authorized to access domain ${name}`) } err.userError = true return bail(err) } // domain already exists if (res.status === 409) { if (this._debug) { console.log('> [debug] Domain already exists (noop)') } return {uid: body.error.uid, code: body.error.code} } if (res.status !== 200) { throw new Error(body.error.message) } return body }) } createCert(domain, {renew} = {}) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] /now/certs #${attempt}`) } const res = await this._fetch('/now/certs', { method: 'POST', body: { domains: [domain], renew } }) if (res.status === 304) { console.log('> Certificate already issued.') return } const body = await res.json() if (this._debug) { console.timeEnd(`> [debug] /now/certs #${attempt}`) } if (body.error) { const {code} = body.error if (code === 'verification_failed') { const err = new Error('The certificate issuer failed to verify ownership of the domain. ' + 'This likely has to do with DNS propagation and caching issues. Please retry later!') err.userError = true // retry throw err } else if (code === 'rate_limited') { const err = new Error(body.error.message) err.userError = true // dont retry return bail(err) } throw new Error(body.error.message) } if (res.status !== 200 && res.status !== 304) { throw new Error('Unhandled error') } return body }, {retries: 5, minTimeout: 30000, maxTimeout: 90000}) } deleteCert(domain) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] /now/certs #${attempt}`) } const res = await this._fetch(`/now/certs/${domain}`, { method: 'DELETE' }) if (res.status !== 200) { const err = new Error(res.body.error.message) err.userError = false if (res.status === 400 || res.status === 404) { return bail(err) } throw err } }) } async remove(deploymentId, {hard}) { const data = {deploymentId, hard} await this.retry(async bail => { if (this._debug) { console.time('> [debug] /remove') } const res = await this._fetch('/now/remove', { method: 'DELETE', body: data }) if (this._debug) { console.timeEnd('> [debug] /remove') } // no retry on 4xx if (res.status >= 400 && res.status < 500) { if (this._debug) { console.log('> [debug] bailing on removal due to %s', res.status) } return bail(responseError(res)) } if (res.status !== 200) { throw new Error('Removing deployment failed') } }) return true } retry(fn, {retries = 3, maxTimeout = Infinity} = {}) { return retry(fn, { retries, maxTimeout, onRetry: this._onRetry }) } _onRetry(err) { if (this._debug) { console.log(`> [debug] Retrying: ${err.stack}`) } } close() { this._agent.close() } get id() { return this._id } get url() { return `https://${this._host}` } get host() { return this._host } get syncAmount() { if (!this._syncAmount) { this._syncAmount = this._missing .map(sha => this._files.get(sha).data.length) .reduce((a, b) => a + b, 0) } return this._syncAmount } _fetch(_url, opts = {}) { opts.headers = opts.headers || {} opts.headers.authorization = `Bearer ${this._token}` opts.headers['user-agent'] = ua return this._agent.fetch(_url, opts) } } function toRelative(path, base) { const fullBase = base.endsWith(SEP) ? base : base + SEP let relative = path.substr(fullBase.length) if (relative.startsWith(SEP)) { relative = relative.substr(1) } return relative.replace(/\\/g, '/') } function responseError(res) { const err = new Error('Response error') err.status = res.status if (res.status === 429) { const retryAfter = res.headers.get('Retry-After') if (retryAfter) { err.retryAfter = parseInt(retryAfter, 10) } } return err }