UNPKG

@now/build-utils

Version:
780 lines (779 loc) 29.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.detectBuilders = exports.detectOutputDirectory = exports.detectApiDirectory = exports.detectApiExtensions = exports.sortFiles = void 0; const minimatch_1 = __importDefault(require("minimatch")); const semver_1 = require("semver"); const path_1 = require("path"); const frameworks_1 = __importDefault(require("@vercel/frameworks")); const _1 = require("./"); const frameworkList = frameworks_1.default; const slugToFramework = new Map(frameworkList.map(f => [f.slug, f])); // We need to sort the file paths by alphabet to make // sure the routes stay in the same order e.g. for deduping function sortFiles(fileA, fileB) { return fileA.localeCompare(fileB); } exports.sortFiles = sortFiles; function detectApiExtensions(builders) { return new Set(builders .filter((b) => { var _a; return Boolean(b.config && b.config.zeroConfig && ((_a = b.src) === null || _a === void 0 ? void 0 : _a.startsWith('api/'))); }) .map(b => path_1.extname(b.src)) .filter(Boolean)); } exports.detectApiExtensions = detectApiExtensions; function detectApiDirectory(builders) { // TODO: We eventually want to save the api directory to // builder.config.apiDirectory so it is only detected once const found = builders.some(b => { var _a; return b.config && b.config.zeroConfig && ((_a = b.src) === null || _a === void 0 ? void 0 : _a.startsWith('api/')); }); return found ? 'api' : null; } exports.detectApiDirectory = detectApiDirectory; // TODO: Replace this function with `config.outputDirectory` function getPublicBuilder(builders) { for (const builder of builders) { if (typeof builder.src === 'string' && _1.isOfficialRuntime('static', builder.use) && /^.*\/\*\*\/\*$/.test(builder.src) && builder.config && builder.config.zeroConfig === true) { return builder; } } return null; } function detectOutputDirectory(builders) { // TODO: We eventually want to save the output directory to // builder.config.outputDirectory so it is only detected once const publicBuilder = getPublicBuilder(builders); return publicBuilder ? publicBuilder.src.replace('/**/*', '') : null; } exports.detectOutputDirectory = detectOutputDirectory; async function detectBuilders(files, pkg, options = {}) { var _a; const errors = []; const warnings = []; const apiBuilders = []; let frontendBuilder = null; const functionError = validateFunctions(options); if (functionError) { return { builders: null, errors: [functionError], warnings, defaultRoutes: null, redirectRoutes: null, rewriteRoutes: null, errorRoutes: null, }; } const sortedFiles = files.sort(sortFiles); const apiSortedFiles = files.sort(sortFilesBySegmentCount); // Keep track of functions that are used const usedFunctions = new Set(); const addToUsedFunctions = (builder) => { const key = Object.keys(builder.config.functions || {})[0]; if (key) usedFunctions.add(key); }; const absolutePathCache = new Map(); const { projectSettings = {} } = options; const { buildCommand, outputDirectory, framework } = projectSettings; const ignoreRuntimes = new Set((_a = slugToFramework.get(framework || '')) === null || _a === void 0 ? void 0 : _a.ignoreRuntimes); const withTag = options.tag ? `@${options.tag}` : ''; const apiMatches = getApiMatches() .filter(b => !ignoreRuntimes.has(b.use)) .map(b => { b.use = `${b.use}${withTag}`; return b; }); // If either is missing we'll make the frontend static const makeFrontendStatic = buildCommand === '' || outputDirectory === ''; // Only used when there is no frontend builder, // but prevents looping over the files again. const usedOutputDirectory = outputDirectory || 'public'; let hasUsedOutputDirectory = false; let hasNoneApiFiles = false; let hasNextApiFiles = false; let fallbackEntrypoint = null; const apiRoutes = []; const dynamicRoutes = []; // API for (const fileName of sortedFiles) { const apiBuilder = maybeGetApiBuilder(fileName, apiMatches, options); if (apiBuilder) { const { routeError, apiRoute, isDynamic } = getApiRoute(fileName, apiSortedFiles, options, absolutePathCache); if (routeError) { return { builders: null, errors: [routeError], warnings, defaultRoutes: null, redirectRoutes: null, rewriteRoutes: null, errorRoutes: null, }; } if (apiRoute) { apiRoutes.push(apiRoute); if (isDynamic) { dynamicRoutes.push(apiRoute); } } addToUsedFunctions(apiBuilder); apiBuilders.push(apiBuilder); continue; } if (!hasUsedOutputDirectory && fileName.startsWith(`${usedOutputDirectory}/`)) { hasUsedOutputDirectory = true; } if (!hasNoneApiFiles && !fileName.startsWith('api/') && fileName !== 'package.json') { hasNoneApiFiles = true; } if (!hasNextApiFiles && (fileName.startsWith('pages/api') || fileName.startsWith('src/pages/api'))) { hasNextApiFiles = true; } if (!fallbackEntrypoint && buildCommand && !fileName.includes('/') && fileName !== 'now.json' && fileName !== 'vercel.json') { fallbackEntrypoint = fileName; } } if (!makeFrontendStatic && (hasBuildScript(pkg) || buildCommand || framework)) { // Framework or Build frontendBuilder = detectFrontBuilder(pkg, files, usedFunctions, fallbackEntrypoint, options); } else { if (pkg && !makeFrontendStatic && !apiBuilders.length && !options.ignoreBuildScript) { // We only show this error when there are no api builders // since the dependencies of the pkg could be used for those errors.push(getMissingBuildScriptError()); return { errors, warnings, builders: null, redirectRoutes: null, defaultRoutes: null, rewriteRoutes: null, errorRoutes: null, }; } // If `outputDirectory` is an empty string, // we'll default to the root directory. if (hasUsedOutputDirectory && outputDirectory !== '') { frontendBuilder = { use: '@vercel/static', src: `${usedOutputDirectory}/**/*`, config: { zeroConfig: true, outputDirectory: usedOutputDirectory, }, }; } else if (apiBuilders.length && hasNoneApiFiles) { // Everything besides the api directory // and package.json can be served as static files frontendBuilder = { use: '@vercel/static', src: '!{api/**,package.json}', config: { zeroConfig: true, }, }; } } const unusedFunctionError = checkUnusedFunctions(frontendBuilder, usedFunctions, options); if (unusedFunctionError) { return { builders: null, errors: [unusedFunctionError], warnings, redirectRoutes: null, defaultRoutes: null, rewriteRoutes: null, errorRoutes: null, }; } const builders = []; if (apiBuilders.length) { builders.push(...apiBuilders); } if (frontendBuilder) { builders.push(frontendBuilder); if (hasNextApiFiles && apiBuilders.length) { warnings.push({ code: 'conflicting_files', message: 'It is not possible to use `api` and `pages/api` at the same time, please only use one option', }); } } const routesResult = getRouteResult(apiRoutes, dynamicRoutes, usedOutputDirectory, apiBuilders, frontendBuilder, options); return { warnings, builders: builders.length ? builders : null, errors: errors.length ? errors : null, redirectRoutes: routesResult.redirectRoutes, defaultRoutes: routesResult.defaultRoutes, rewriteRoutes: routesResult.rewriteRoutes, errorRoutes: routesResult.errorRoutes, }; } exports.detectBuilders = detectBuilders; function maybeGetApiBuilder(fileName, apiMatches, options) { if (!fileName.startsWith('api/')) { return null; } if (fileName.includes('/.')) { return null; } if (fileName.includes('/_')) { return null; } if (fileName.includes('/node_modules/')) { return null; } if (fileName.endsWith('.d.ts')) { return null; } const match = apiMatches.find(({ src = '**' }) => { return src === fileName || minimatch_1.default(fileName, src); }); const { fnPattern, func } = getFunction(fileName, options); const use = (func && func.runtime) || (match && match.use); if (!use) { return null; } const config = { zeroConfig: true }; if (fnPattern && func) { config.functions = { [fnPattern]: func }; if (func.includeFiles) { config.includeFiles = func.includeFiles; } if (func.excludeFiles) { config.excludeFiles = func.excludeFiles; } } const builder = { use, src: fileName, config, }; return builder; } function getFunction(fileName, { functions = {} }) { const keys = Object.keys(functions); if (!keys.length) { return { fnPattern: null, func: null }; } const func = keys.find(key => key === fileName || minimatch_1.default(fileName, key)); return func ? { fnPattern: func, func: functions[func] } : { fnPattern: null, func: null }; } function getApiMatches() { const config = { zeroConfig: true }; return [ { src: 'api/**/*.js', use: `@vercel/node`, config }, { src: 'api/**/*.ts', use: `@vercel/node`, config }, { src: 'api/**/!(*_test).go', use: `@vercel/go`, config }, { src: 'api/**/*.py', use: `@vercel/python`, config }, { src: 'api/**/*.rb', use: `@vercel/ruby`, config }, ]; } function hasBuildScript(pkg) { const { scripts = {} } = pkg || {}; return Boolean(scripts && scripts['build']); } function detectFrontBuilder(pkg, files, usedFunctions, fallbackEntrypoint, options) { const { tag, projectSettings = {} } = options; const withTag = tag ? `@${tag}` : ''; const { createdAt = 0 } = projectSettings; let { framework } = projectSettings; const config = { zeroConfig: true, }; if (framework) { config.framework = framework; } if (projectSettings.devCommand) { config.devCommand = projectSettings.devCommand; } if (typeof projectSettings.installCommand === 'string') { config.installCommand = projectSettings.installCommand; } if (projectSettings.buildCommand) { config.buildCommand = projectSettings.buildCommand; } if (projectSettings.outputDirectory) { config.outputDirectory = projectSettings.outputDirectory; } if (pkg && (framework === undefined || createdAt < Date.parse('2020-03-01'))) { const deps = { ...pkg.dependencies, ...pkg.devDependencies, }; if (deps['next']) { framework = 'nextjs'; } } if (options.functions) { // When the builder is not used yet we'll use it for the frontend Object.entries(options.functions).forEach(([key, func]) => { if (!usedFunctions.has(key)) { if (!config.functions) config.functions = {}; config.functions[key] = { ...func }; } }); } const f = slugToFramework.get(framework || ''); if (f && f.useRuntime) { const { src, use } = f.useRuntime; return { src, use: `${use}${withTag}`, config }; } // Entrypoints for other frameworks // TODO - What if just a build script is provided, but no entrypoint. const entrypoints = new Set([ 'package.json', 'config.yaml', 'config.toml', 'config.json', '_config.yml', 'config.yml', 'config.rb', ]); const source = pkg ? 'package.json' : files.find(file => entrypoints.has(file)) || fallbackEntrypoint || 'package.json'; return { src: source || 'package.json', use: `@vercel/static-build${withTag}`, config, }; } function getMissingBuildScriptError() { return { code: 'missing_build_script', message: 'Your `package.json` file is missing a `build` property inside the `scripts` property.' + '\nLearn More: https://vercel.com/docs/v2/platform/frequently-asked-questions#missing-build-script', }; } function validateFunctions({ functions = {} }) { for (const [path, func] of Object.entries(functions)) { if (path.length > 256) { return { code: 'invalid_function_glob', message: 'Function globs must be less than 256 characters long.', }; } if (!func || typeof func !== 'object') { return { code: 'invalid_function', message: 'Function must be an object.', }; } if (Object.keys(func).length === 0) { return { code: 'invalid_function', message: 'Function must contain at least one property.', }; } if (func.maxDuration !== undefined && (func.maxDuration < 1 || func.maxDuration > 900 || !Number.isInteger(func.maxDuration))) { return { code: 'invalid_function_duration', message: 'Functions must have a duration between 1 and 900.', }; } if (func.memory !== undefined && (func.memory < 128 || func.memory > 3008 || func.memory % 64 !== 0)) { return { code: 'invalid_function_memory', message: 'Functions must have a memory value between 128 and 3008 in steps of 64.', }; } if (path.startsWith('/')) { return { code: 'invalid_function_source', message: `The function path "${path}" is invalid. The path must be relative to your project root and therefore cannot start with a slash.`, }; } if (func.runtime !== undefined) { const tag = `${func.runtime}`.split('@').pop(); if (!tag || !semver_1.valid(tag)) { return { code: 'invalid_function_runtime', message: 'Function Runtimes must have a valid version, for example `now-php@1.0.0`.', }; } } if (func.includeFiles !== undefined) { if (typeof func.includeFiles !== 'string') { return { code: 'invalid_function_property', message: `The property \`includeFiles\` must be a string.`, }; } } if (func.excludeFiles !== undefined) { if (typeof func.excludeFiles !== 'string') { return { code: 'invalid_function_property', message: `The property \`excludeFiles\` must be a string.`, }; } } } return null; } function checkUnusedFunctions(frontendBuilder, usedFunctions, options) { const unusedFunctions = new Set(Object.keys(options.functions || {}).filter(key => !usedFunctions.has(key))); if (!unusedFunctions.size) { return null; } // Next.js can use functions only for `src/pages` or `pages` if (frontendBuilder && _1.isOfficialRuntime('next', frontendBuilder.use)) { for (const fnKey of unusedFunctions.values()) { if (fnKey.startsWith('pages/') || fnKey.startsWith('src/pages')) { unusedFunctions.delete(fnKey); } else { return { code: 'unused_function', message: `The pattern "${fnKey}" defined in \`functions\` doesn't match any Serverless Functions.`, action: 'Learn More', link: 'https://vercel.link/unmatched-function-pattern', }; } } } if (unusedFunctions.size) { const [fnKey] = Array.from(unusedFunctions); return { code: 'unused_function', message: `The pattern "${fnKey}" defined in \`functions\` doesn't match any Serverless Functions inside the \`api\` directory.`, action: 'Learn More', link: 'https://vercel.link/unmatched-function-pattern', }; } return null; } function getApiRoute(fileName, sortedFiles, options, absolutePathCache) { const conflictingSegment = getConflictingSegment(fileName); if (conflictingSegment) { return { apiRoute: null, isDynamic: false, routeError: { code: 'conflicting_path_segment', message: `The segment "${conflictingSegment}" occurs more than ` + `one time in your path "${fileName}". Please make sure that ` + `every segment in a path is unique.`, }, }; } const occurrences = pathOccurrences(fileName, sortedFiles, absolutePathCache); if (occurrences.length > 0) { const messagePaths = concatArrayOfText(occurrences.map(name => `"${name}"`)); return { apiRoute: null, isDynamic: false, routeError: { code: 'conflicting_file_path', message: `Two or more files have conflicting paths or names. ` + `Please make sure path segments and filenames, without their extension, are unique. ` + `The path "${fileName}" has conflicts with ${messagePaths}.`, }, }; } const out = createRouteFromPath(fileName, Boolean(options.featHandleMiss), Boolean(options.cleanUrls)); return { apiRoute: out.route, isDynamic: out.isDynamic, routeError: null, }; } // Checks if a placeholder with the same name is used // multiple times inside the same path function getConflictingSegment(filePath) { const segments = new Set(); for (const segment of filePath.split('/')) { const name = getSegmentName(segment); if (name !== null && segments.has(name)) { return name; } if (name) { segments.add(name); } } return null; } // Takes a filename or foldername, strips the extension // gets the part between the "[]" brackets. // It will return `null` if there are no brackets // and therefore no segment. function getSegmentName(segment) { const { name } = path_1.parse(segment); if (name.startsWith('[') && name.endsWith(']')) { return name.slice(1, -1); } return null; } function getAbsolutePath(unresolvedPath) { const { dir, name } = path_1.parse(unresolvedPath); const parts = joinPath(dir, name).split('/'); return parts.map(part => part.replace(/\[.*\]/, '1')).join('/'); } // Counts how often a path occurs when all placeholders // got resolved, so we can check if they have conflicts function pathOccurrences(fileName, files, absolutePathCache) { let currentAbsolutePath = absolutePathCache.get(fileName); if (!currentAbsolutePath) { currentAbsolutePath = getAbsolutePath(fileName); absolutePathCache.set(fileName, currentAbsolutePath); } const prev = []; // Do not call expensive functions like `minimatch` in here // because we iterate over every file. for (const file of files) { if (file === fileName) { continue; } let absolutePath = absolutePathCache.get(file); if (!absolutePath) { absolutePath = getAbsolutePath(file); absolutePathCache.set(file, absolutePath); } if (absolutePath === currentAbsolutePath) { prev.push(file); } else if (partiallyMatches(fileName, file)) { prev.push(file); } } return prev; } function joinPath(...segments) { const joinedPath = segments.join('/'); return joinedPath.replace(/\/{2,}/g, '/'); } function escapeName(name) { const special = '[]^$.|?*+()'.split(''); for (const char of special) { name = name.replace(new RegExp(`\\${char}`, 'g'), `\\${char}`); } return name; } function concatArrayOfText(texts) { if (texts.length <= 2) { return texts.join(' and '); } const last = texts.pop(); return `${texts.join(', ')}, and ${last}`; } // Check if the path partially matches and has the same // name for the path segment at the same position function partiallyMatches(pathA, pathB) { const partsA = pathA.split('/'); const partsB = pathB.split('/'); const long = partsA.length > partsB.length ? partsA : partsB; const short = long === partsA ? partsB : partsA; let index = 0; for (const segmentShort of short) { const segmentLong = long[index]; const nameLong = getSegmentName(segmentLong); const nameShort = getSegmentName(segmentShort); // If there are no segments or the paths differ we // return as they are not matching if (segmentShort !== segmentLong && (!nameLong || !nameShort)) { return false; } if (nameLong !== nameShort) { return true; } index += 1; } return false; } function createRouteFromPath(filePath, featHandleMiss, cleanUrls) { const parts = filePath.split('/'); let counter = 1; const query = []; let isDynamic = false; const srcParts = parts.map((segment, i) => { const name = getSegmentName(segment); const isLast = i === parts.length - 1; if (name !== null) { // We can't use `URLSearchParams` because `$` would get escaped query.push(`${name}=$${counter++}`); isDynamic = true; return `([^/]+)`; } else if (isLast) { const { name: fileName, ext } = path_1.parse(segment); const isIndex = fileName === 'index'; const prefix = isIndex ? '/' : ''; const names = [ isIndex ? prefix : `${fileName}/`, prefix + escapeName(fileName), featHandleMiss && cleanUrls ? '' : prefix + escapeName(fileName) + escapeName(ext), ].filter(Boolean); // Either filename with extension, filename without extension // or nothing when the filename is `index`. // When `cleanUrls: true` then do *not* add the filename with extension. return `(${names.join('|')})${isIndex ? '?' : ''}`; } return segment; }); const { name: fileName, ext } = path_1.parse(filePath); const isIndex = fileName === 'index'; const queryString = `${query.length ? '?' : ''}${query.join('&')}`; const src = isIndex ? `^/${srcParts.slice(0, -1).join('/')}${srcParts.slice(-1)[0]}$` : `^/${srcParts.join('/')}$`; let route; if (featHandleMiss) { const extensionless = ext ? filePath.slice(0, -ext.length) : filePath; route = { src, dest: `/${extensionless}${queryString}`, check: true, }; } else { route = { src, dest: `/${filePath}${queryString}`, }; } return { route, isDynamic }; } function getRouteResult(apiRoutes, dynamicRoutes, outputDirectory, apiBuilders, frontendBuilder, options) { var _a, _b; const defaultRoutes = []; const redirectRoutes = []; const rewriteRoutes = []; const errorRoutes = []; const framework = ((_a = frontendBuilder === null || frontendBuilder === void 0 ? void 0 : frontendBuilder.config) === null || _a === void 0 ? void 0 : _a.framework) || ''; const use = (frontendBuilder === null || frontendBuilder === void 0 ? void 0 : frontendBuilder.use) || ''; const isNextjs = framework === 'nextjs' || use.startsWith('@vercel/next'); const ignoreRuntimes = (_b = slugToFramework.get(framework)) === null || _b === void 0 ? void 0 : _b.ignoreRuntimes; if (apiRoutes && apiRoutes.length > 0) { if (options.featHandleMiss) { const extSet = detectApiExtensions(apiBuilders); if (extSet.size > 0) { const exts = Array.from(extSet) .map(ext => ext.slice(1)) .join('|'); const extGroup = `(?:\\.(?:${exts}))`; if (options.cleanUrls) { redirectRoutes.push({ src: `^/(api(?:.+)?)/index${extGroup}?/?$`, headers: { Location: options.trailingSlash ? '/$1/' : '/$1' }, status: 308, }); redirectRoutes.push({ src: `^/api/(.+)${extGroup}/?$`, headers: { Location: options.trailingSlash ? '/api/$1/' : '/api/$1', }, status: 308, }); } else { defaultRoutes.push({ handle: 'miss' }); defaultRoutes.push({ src: `^/api/(.+)${extGroup}$`, dest: '/api/$1', check: true, }); } } rewriteRoutes.push(...dynamicRoutes); if (typeof ignoreRuntimes === 'undefined') { // This route is only necessary to hide the directory listing // to avoid enumerating serverless function names. // But it causes issues in `vc dev` for frameworks that handle // their own functions such as redwood, so we ignore. rewriteRoutes.push({ src: '^/api(/.*)?$', status: 404, }); } } else { defaultRoutes.push(...apiRoutes); if (apiRoutes.length) { defaultRoutes.push({ status: 404, src: '^/api(/.*)?$', }); } } } if (outputDirectory && frontendBuilder && !options.featHandleMiss && _1.isOfficialRuntime('static', frontendBuilder.use)) { defaultRoutes.push({ src: '/(.*)', dest: `/${outputDirectory}/$1`, }); } if (options.featHandleMiss && !isNextjs) { // Exclude Next.js to avoid overriding custom error page // https://nextjs.org/docs/advanced-features/custom-error-page errorRoutes.push({ status: 404, src: '^/(?!.*api).*$', dest: options.cleanUrls ? '/404' : '/404.html', }); } return { defaultRoutes, redirectRoutes, rewriteRoutes, errorRoutes, }; } function sortFilesBySegmentCount(fileA, fileB) { const lengthA = fileA.split('/').length; const lengthB = fileB.split('/').length; if (lengthA > lengthB) { return -1; } if (lengthA < lengthB) { return 1; } // Paths that have the same segment length but // less placeholders are preferred const countSegments = (prev, segment) => getSegmentName(segment) ? prev + 1 : 0; const segmentLengthA = fileA.split('/').reduce(countSegments, 0); const segmentLengthB = fileB.split('/').reduce(countSegments, 0); if (segmentLengthA > segmentLengthB) { return 1; } if (segmentLengthA < segmentLengthB) { return -1; } return fileA.localeCompare(fileB); }