Skip to content

Commit

Permalink
feat: edge handlers deploy (#1244)
Browse files Browse the repository at this point in the history
  • Loading branch information
erezrokah authored Sep 21, 2020
1 parent 9ce3a91 commit a0d983a
Show file tree
Hide file tree
Showing 14 changed files with 451 additions and 112 deletions.
30 changes: 21 additions & 9 deletions src/commands/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ const path = require('path')
const chalk = require('chalk')
const { flags } = require('@oclif/command')
const get = require('lodash.get')
const fs = require('fs')
const { promisify } = require('util')
const prettyjson = require('prettyjson')
const ora = require('ora')
const logSymbols = require('log-symbols')
Expand All @@ -16,8 +14,8 @@ const isObject = require('lodash.isobject')
const SitesCreateCommand = require('./sites/create')
const LinkCommand = require('./link')
const { NETLIFYDEV, NETLIFYDEVLOG, NETLIFYDEVERR } = require('../utils/logo')

const statAsync = promisify(fs.stat)
const { statAsync } = require('../lib/fs')
const { deployEdgeHandlers } = require('../utils/edge-handlers')

const DEFAULT_DEPLOY_TIMEOUT = 1.2e6

Expand Down Expand Up @@ -136,6 +134,7 @@ const validateFolders = async ({ deployFolder, functionsFolder, error, log }) =>
const runDeploy = async ({
flags,
deployToProduction,
site,
siteData,
api,
siteId,
Expand Down Expand Up @@ -170,15 +169,28 @@ const runDeploy = async ({
log('Deploying to draft URL...')
}

const draft = !deployToProduction && !alias
const title = flags.message
results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } })
const deployId = results.id

const silent = flags.json || flags.silent
await deployEdgeHandlers({
site,
deployId,
api,
silent,
error,
warn,
})
results = await api.deploy(siteId, deployFolder, {
configPath,
fnDir: functionsFolder,
statusCb: flags.json || flags.silent ? () => {} : deployProgressCb(),
draft: !deployToProduction && !alias,
message: flags.message,
statusCb: silent ? () => {} : deployProgressCb(),
deployTimeout: flags.timeout * 1000 || DEFAULT_DEPLOY_TIMEOUT,
syncFileLimit: 100,
branch: alias,
// pass an existing deployId to update
deployId,
})
} catch (e) {
switch (true) {
Expand Down Expand Up @@ -212,7 +224,6 @@ const runDeploy = async ({
const logsUrl = `${get(results, 'deploy.admin_url')}/deploys/${get(results, 'deploy.id')}`

return {
name: results.deploy.deployId,
siteId: results.deploy.site_id,
siteName: results.deploy.name,
deployId: results.deployId,
Expand Down Expand Up @@ -359,6 +370,7 @@ class DeployCommand extends Command {
const results = await runDeploy({
flags,
deployToProduction,
site,
siteData,
api,
siteId,
Expand Down
78 changes: 78 additions & 0 deletions src/lib/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// This file should be used to wrap API methods that are not part of our open API spec yet
// Once they become part of the spec, js-client should be used
const fetch = require('node-fetch')

const getHeaders = ({ token }) => {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}
}

const getErrorMessage = async ({ response }) => {
const contentType = response.headers.get('content-type')
if (contentType && contentType.indexOf('application/json') !== -1) {
const json = await response.json()
return json.message
} else {
const text = await response.text()
return text
}
}

const checkResponse = async ({ response }) => {
if (!response.ok) {
const message = await getErrorMessage({ response }).catch(() => undefined)
const errorPostfix = message && message ? ` and message '${message}'` : ''
throw new Error(`Request failed with status '${response.status}'${errorPostfix}`)
}
}

const getApiUrl = ({ api }) => {
return `${api.scheme}://${api.host}${api.pathPrefix}`
}

const apiPost = async ({ api, path, data }) => {
const apiUrl = getApiUrl({ api })
const response = await fetch(`${apiUrl}/${path}`, {
method: 'POST',
body: JSON.stringify(data),
headers: getHeaders({ token: api.accessToken }),
agent: api.agent,
})

await checkResponse({ response })

return response
}

const uploadEdgeHandlers = async ({ api, deployId, bundleBuffer, manifest }) => {
const response = await apiPost({ api, path: `deploys/${deployId}/edge_handlers`, data: manifest })
const { error, exists, upload_url: uploadUrl } = await response.json()
if (error) {
throw new Error(error)
}

if (exists) {
return false
}

if (!uploadUrl) {
throw new Error('Missing upload URL')
}

const putResponse = await fetch(uploadUrl, {
method: 'PUT',
body: bundleBuffer,
headers: {
'Content-Type': 'application/javascript',
},
agent: api.agent,
})

await checkResponse({ response: putResponse })

return true
}

module.exports = { uploadEdgeHandlers }
26 changes: 26 additions & 0 deletions src/lib/fs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const fs = require('fs')
const { promisify } = require('util')

const statAsync = promisify(fs.stat)
const readFileAsync = promisify(fs.readFile)
const writeFileAsync = promisify(fs.writeFile)
const accessAsync = promisify(fs.access)

const readFileAsyncCatchError = async filepath => {
try {
return { content: await readFileAsync(filepath) }
} catch (error) {
return { error }
}
}

const fileExistsAsync = async filePath => {
try {
await accessAsync(filePath, fs.F_OK)
return true
} catch (_) {
return false
}
}

module.exports = { statAsync, readFileAsync, readFileAsyncCatchError, writeFileAsync, fileExistsAsync }
21 changes: 21 additions & 0 deletions src/lib/spinner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ora = require('ora')
const logSymbols = require('log-symbols')

const startSpinner = ({ text }) => {
return ora({
text,
}).start()
}

const stopSpinner = ({ spinner, text, error }) => {
if (!spinner) {
return
}
const symbol = error ? logSymbols.error : logSymbols.success
spinner.stopAndPersist({
text,
symbol,
})
}

module.exports = { startSpinner, stopSpinner }
35 changes: 20 additions & 15 deletions src/utils/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ const { NETLIFY_AUTH_TOKEN, NETLIFY_API_URL } = process.env
// Todo setup client for multiple environments
const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'

const getToken = tokenFromFlag => {
// 1. First honor command flag --auth
if (tokenFromFlag) {
return [tokenFromFlag, 'flag']
}
// 2. then Check ENV var
if (NETLIFY_AUTH_TOKEN && NETLIFY_AUTH_TOKEN !== 'null') {
return [NETLIFY_AUTH_TOKEN, 'env']
}
// 3. If no env var use global user setting
const userId = globalConfig.get('userId')
const tokenFromConfig = globalConfig.get(`users.${userId}.auth.token`)
if (tokenFromConfig) {
return [tokenFromConfig, 'config']
}
return [null, 'not found']
}

class BaseCommand extends Command {
constructor(...args) {
super(...args)
Expand Down Expand Up @@ -179,21 +197,7 @@ class BaseCommand extends Command {
* @return {[string, string]} - tokenValue & location of resolved Netlify API token
*/
getConfigToken(tokenFromFlag) {
// 1. First honor command flag --auth
if (tokenFromFlag) {
return [tokenFromFlag, 'flag']
}
// 2. then Check ENV var
if (NETLIFY_AUTH_TOKEN && NETLIFY_AUTH_TOKEN !== 'null') {
return [NETLIFY_AUTH_TOKEN, 'env']
}
// 3. If no env var use global user setting
const userId = globalConfig.get('userId')
const tokenFromConfig = globalConfig.get(`users.${userId}.auth.token`)
if (tokenFromConfig) {
return [tokenFromConfig, 'config']
}
return [null, 'not found']
return getToken(tokenFromFlag)
}

async authenticate(tokenFromFlag) {
Expand Down Expand Up @@ -291,4 +295,5 @@ BaseCommand.flags = {
}),
}

BaseCommand.getToken = getToken
module.exports = BaseCommand
88 changes: 88 additions & 0 deletions src/utils/edge-handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const path = require('path')
const { statAsync, readFileAsyncCatchError } = require('../lib/fs')
const { uploadEdgeHandlers } = require('../lib/api')
const { startSpinner, stopSpinner } = require('../lib/spinner')

const MANIFEST_FILENAME = 'manifest.json'
const EDGE_HANDLERS_FOLDER = '.netlify/edge-handlers'

const validateEdgeHandlerFolder = async ({ site, error }) => {
try {
const resolvedFolder = path.resolve(site.root, EDGE_HANDLERS_FOLDER)
const stat = await statAsync(resolvedFolder)
if (!stat.isDirectory()) {
error(`Edge Handlers folder ${EDGE_HANDLERS_FOLDER} must be a path to a directory`)
}
return resolvedFolder
} catch (e) {
// ignore errors at the moment
// TODO: report error if 'edge_handlers' config exists after
// https://github.com/netlify/build/pull/1829 is published
}
}

const readBundleAndManifest = async ({ edgeHandlersResolvedFolder, error }) => {
const manifestPath = path.resolve(edgeHandlersResolvedFolder, MANIFEST_FILENAME)
const { content: manifest, error: manifestError } = await readFileAsyncCatchError(manifestPath)
if (manifestError) {
error(`Could not read Edge Handlers manifest file ${manifestPath}: ${manifestError.message}`)
}

let manifestJson
try {
manifestJson = JSON.parse(manifest)
} catch (e) {
error(`Edge Handlers manifest file is not a valid JSON file: ${e.message}`)
}

if (!manifestJson.sha) {
error(`Edge Handlers manifest file is missing the 'sha' property`)
}

const bundlePath = path.resolve(edgeHandlersResolvedFolder, manifestJson.sha)
const { content: bundleBuffer, error: bundleError } = await readFileAsyncCatchError(bundlePath)

if (bundleError) {
error(`Could not read Edge Handlers bundle file ${bundlePath}: ${bundleError.message}`)
}

return { bundleBuffer, manifest: manifestJson }
}

const deployEdgeHandlers = async ({ site, deployId, api, silent, error, warn }) => {
const edgeHandlersResolvedFolder = await validateEdgeHandlerFolder({ site, error })
if (edgeHandlersResolvedFolder) {
let spinner
try {
spinner = silent
? null
: startSpinner({ text: `Deploying Edge Handlers from directory ${edgeHandlersResolvedFolder}` })

const { bundleBuffer, manifest } = await readBundleAndManifest({ edgeHandlersResolvedFolder, error })
// returns false if the bundle exists, true on success, throws on error
const success = await uploadEdgeHandlers({
api,
deployId,
bundleBuffer,
manifest,
})

const text = success
? `Finished deploying Edge Handlers from directory: ${edgeHandlersResolvedFolder}`
: `Skipped deploying Edge Handlers since the bundle already exists`
stopSpinner({ spinner, text, error: false })
} catch (e) {
const text = `Failed deploying Edge Handlers: ${e.message}`
stopSpinner({ spinner, text, error: true })
try {
await api.cancelSiteDeploy({ deploy_id: deployId })
} catch (e) {
warn(`Failed canceling deploy with id ${deployId}: ${e.message}`)
}
// no need to report the error again
error('')
}
}
}

module.exports = { deployEdgeHandlers }
12 changes: 4 additions & 8 deletions src/utils/env.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
const path = require('path')
const fs = require('fs')
const { promisify } = require('util')
const dotenv = require('dotenv')

const fileStat = promisify(fs.stat)
const readFile = promisify(fs.readFile)
const { statAsync, readFileAsync } = require('../lib/fs')

async function getEnvSettings(projectDir) {
const envDevelopmentFile = path.resolve(projectDir, '.env.development')
Expand All @@ -13,17 +9,17 @@ async function getEnvSettings(projectDir) {
const settings = {}

try {
if ((await fileStat(envFile)).isFile()) settings.file = envFile
if ((await statAsync(envFile)).isFile()) settings.file = envFile
} catch (err) {
// nothing
}
try {
if ((await fileStat(envDevelopmentFile)).isFile()) settings.file = envDevelopmentFile
if ((await statAsync(envDevelopmentFile)).isFile()) settings.file = envDevelopmentFile
} catch (err) {
// nothing
}

if (settings.file) settings.vars = dotenv.parse(await readFile(settings.file)) || {}
if (settings.file) settings.vars = dotenv.parse(await readFileAsync(settings.file)) || {}

return settings
}
Expand Down
Loading

0 comments on commit a0d983a

Please sign in to comment.