Files
vercel/packages/cli/src/util/dev/server.ts
Ethan Arrowood af239b5fa5 [internals] Create @vercel-internals/types (#9608)
Moves the type file out of the cli package and into its own standalone
package. utilizes `@vercel/style-guide` too for typescript config,
eslint, and prettier.
2023-03-07 08:44:25 -07:00

2628 lines
75 KiB
TypeScript

import url, { URL } from 'url';
import http from 'http';
import fs from 'fs-extra';
import chalk from 'chalk';
import fetch from 'node-fetch';
import plural from 'pluralize';
import rawBody from 'raw-body';
import listen from 'async-listen';
import minimatch from 'minimatch';
import httpProxy from 'http-proxy';
import { randomBytes } from 'crypto';
import serveHandler from 'serve-handler';
import { watch, FSWatcher } from 'chokidar';
import { parse as parseDotenv } from 'dotenv';
import path, { isAbsolute, basename, dirname, extname, join } from 'path';
import once from '@tootallnate/once';
import directoryTemplate from 'serve-handler/src/directory';
import getPort from 'get-port';
import isPortReachable from 'is-port-reachable';
import deepEqual from 'fast-deep-equal';
import npa from 'npm-package-arg';
import type { ChildProcess } from 'child_process';
import { getVercelIgnore, fileNameSymbol } from '@vercel/client';
import {
getTransformedRoutes,
appendRoutesToPhase,
HandleValue,
Route,
} from '@vercel/routing-utils';
import {
Builder,
cloneEnv,
Env,
getNodeBinPath,
StartDevServerResult,
FileFsRef,
PackageJson,
spawnCommand,
} from '@vercel/build-utils';
import {
detectBuilders,
detectApiDirectory,
detectApiExtensions,
isOfficialRuntime,
} from '@vercel/fs-detectors';
import frameworkList from '@vercel/frameworks';
import cmd from '../output/cmd';
import link from '../output/link';
import sleep from '../sleep';
import { Output } from '../output';
import { relative } from '../path-helpers';
import { getDistTag } from '../get-dist-tag';
import getVercelConfigPath from '../config/local-path';
import { MissingDotenvVarsError } from '../errors-ts';
import cliPkg from '../pkg';
import { getVercelDirectory } from '../projects/link';
import { staticFiles as getFiles } from '../get-files';
import { validateConfig } from '../validate-config';
import { devRouter, getRoutesTypes } from './router';
import getMimeType from './mime-type';
import { executeBuild, getBuildMatches, shutdownBuilder } from './builder';
import { generateErrorMessage, generateHttpStatusDescription } from './errors';
// HTML templates
import errorTemplate from './templates/error';
import errorTemplateBase from './templates/error_base';
import errorTemplate404 from './templates/error_404';
import errorTemplate502 from './templates/error_502';
import redirectTemplate from './templates/redirect';
import {
VercelConfig,
DevServerOptions,
BuildMatch,
BuildResult,
BuilderInputs,
BuilderOutput,
HttpHandler,
InvokePayload,
InvokeResult,
ListenSpec,
RouteResult,
HttpHeadersConfig,
EnvConfigs,
} from './types';
import { ProjectSettings } from '@vercel-internals/types';
import { treeKill } from '../tree-kill';
import { applyOverriddenHeaders, nodeHeadersToFetchHeaders } from './headers';
import { formatQueryString, parseQueryString } from './parse-query-string';
import {
errorToString,
isErrnoException,
isError,
isSpawnError,
} from '@vercel/error-utils';
import isURL from './is-url';
import { pickOverrides } from '../projects/project-settings';
import { replaceLocalhost } from './parse-listen';
const frontendRuntimeSet = new Set(
frameworkList.map(f => f.useRuntime?.use || '@vercel/static-build')
);
interface FSEvent {
type: string;
path: string;
}
type WithFileNameSymbol<T> = T & {
[fileNameSymbol]: string;
};
function sortBuilders(buildA: Builder, buildB: Builder) {
if (buildA && buildA.use && isOfficialRuntime('static-build', buildA.use)) {
return 1;
}
if (buildB && buildB.use && isOfficialRuntime('static-build', buildB.use)) {
return -1;
}
return 0;
}
export default class DevServer {
public cwd: string;
public output: Output;
public proxy: httpProxy;
public envConfigs: EnvConfigs;
public files: BuilderInputs;
private _address: URL | undefined;
public get address(): URL {
if (!this._address) {
throw new Error(
'Invalid access to `address` because `start` has not yet populated `this.address`.'
);
}
return this._address;
}
public devCacheDir: string;
private currentDevCommand?: string;
private caseSensitive: boolean;
private apiDir: string | null;
private apiExtensions: Set<string>;
private server: http.Server;
private stopping: boolean;
private buildMatches: Map<string, BuildMatch>;
private inProgressBuilds: Map<string, Promise<void>>;
private watcher?: FSWatcher;
private watchAggregationId: NodeJS.Timer | null;
private watchAggregationEvents: FSEvent[];
private watchAggregationTimeout: number;
private filter: (path: string) => boolean;
private podId: string;
private devProcess?: ChildProcess;
private devProcessOrigin?: string;
private devServerPids: Set<number>;
private originalProjectSettings?: ProjectSettings;
private projectSettings?: ProjectSettings;
private vercelConfigWarning: boolean;
private getVercelConfigPromise: Promise<VercelConfig> | null;
private blockingBuildsPromise: Promise<void> | null;
private startPromise: Promise<void> | null;
private envValues: Record<string, string>;
constructor(cwd: string, options: DevServerOptions) {
this.cwd = cwd;
this.output = options.output;
this.envConfigs = { buildEnv: {}, runEnv: {}, allEnv: {} };
this.envValues = options.envValues || {};
this.files = {};
this.originalProjectSettings = options.projectSettings;
this.projectSettings = options.projectSettings;
this.caseSensitive = false;
this.apiDir = null;
this.apiExtensions = new Set();
this.proxy = httpProxy.createProxyServer({
changeOrigin: true,
ws: true,
xfwd: true,
});
this.proxy.on('proxyRes', proxyRes => {
// override "server" header, like production
proxyRes.headers['server'] = 'Vercel';
});
this.server = http.createServer(this.devServerHandler);
this.server.timeout = 0; // Disable timeout
this.stopping = false;
this.buildMatches = new Map();
this.inProgressBuilds = new Map();
this.devCacheDir = join(getVercelDirectory(cwd), 'cache');
this.vercelConfigWarning = false;
this.getVercelConfigPromise = null;
this.blockingBuildsPromise = null;
this.startPromise = null;
this.watchAggregationId = null;
this.watchAggregationEvents = [];
this.watchAggregationTimeout = 500;
this.filter = path => Boolean(path);
this.podId = Math.random().toString(32).slice(-5);
this.devServerPids = new Set();
}
async exit(code = 1) {
await this.stop(code);
process.exit(code);
}
enqueueFsEvent(type: string, path: string): void {
this.watchAggregationEvents.push({ type, path });
if (this.watchAggregationId === null) {
this.watchAggregationId = setTimeout(() => {
const events = this.watchAggregationEvents.slice();
this.watchAggregationEvents.length = 0;
this.watchAggregationId = null;
this.handleFilesystemEvents(events);
}, this.watchAggregationTimeout);
}
}
async handleFilesystemEvents(events: FSEvent[]): Promise<void> {
this.output.debug(`Filesystem watcher notified of ${events.length} events`);
const filesChanged: Set<string> = new Set();
const filesRemoved: Set<string> = new Set();
const distPaths: string[] = [];
for (const buildMatch of this.buildMatches.values()) {
for (const buildResult of buildMatch.buildResults.values()) {
if (buildResult.distPath) {
distPaths.push(buildResult.distPath);
}
}
}
events = events.filter(event =>
distPaths.every(distPath => !event.path.startsWith(distPath))
);
// First, update the `files` mapping of source files
for (const event of events) {
if (event.type === 'add') {
await this.handleFileCreated(event.path, filesChanged, filesRemoved);
} else if (event.type === 'unlink') {
this.handleFileDeleted(event.path, filesChanged, filesRemoved);
} else if (event.type === 'change') {
await this.handleFileModified(event.path, filesChanged, filesRemoved);
}
}
const vercelConfig = await this.getVercelConfig();
// Update the build matches in case an entrypoint was created or deleted
await this.updateBuildMatches(vercelConfig);
const filesChangedArray = [...filesChanged];
const filesRemovedArray = [...filesRemoved];
// Trigger rebuilds of any existing builds that are dependent
// on one of the files that has changed
const needsRebuild: Map<BuildResult, [string | null, BuildMatch]> =
new Map();
for (const match of this.buildMatches.values()) {
for (const [requestPath, result] of match.buildResults) {
// If the `BuildResult` is already queued for a re-build,
// then we can skip subsequent lookups
if (needsRebuild.has(result)) continue;
if (Array.isArray(result.watch)) {
for (const pattern of result.watch) {
if (
minimatches(filesChangedArray, pattern) ||
minimatches(filesRemovedArray, pattern)
) {
needsRebuild.set(result, [requestPath, match]);
break;
}
}
}
}
}
if (needsRebuild.size > 0) {
this.output.debug(`Triggering ${needsRebuild.size} rebuilds`);
if (filesChangedArray.length > 0) {
this.output.debug(`Files changed: ${filesChangedArray.join(', ')}`);
}
if (filesRemovedArray.length > 0) {
this.output.debug(`Files removed: ${filesRemovedArray.join(', ')}`);
}
for (const [result, [requestPath, match]] of needsRebuild) {
if (
requestPath === null ||
(await shouldServe(
match,
this.files,
requestPath,
this,
vercelConfig
))
) {
this.triggerBuild(
match,
requestPath,
null,
vercelConfig,
result,
filesChangedArray,
filesRemovedArray
).catch((err: Error) => {
this.output.warn(
`An error occurred while rebuilding \`${match.src}\`:`
);
console.error(err.stack);
});
} else {
this.output.debug(
`Not rebuilding because \`shouldServe()\` returned \`false\` for "${match.use}" request path "${requestPath}"`
);
}
}
}
}
async handleFileCreated(
fsPath: string,
changed: Set<string>,
removed: Set<string>
): Promise<void> {
const name = relative(this.cwd, fsPath);
try {
await this.getVercelConfig();
this.files[name] = await FileFsRef.fromFsPath({ fsPath });
const extensionless = this.getExtensionlessFile(name);
if (extensionless) {
this.files[extensionless] = await FileFsRef.fromFsPath({ fsPath });
}
fileChanged(name, changed, removed);
this.output.debug(`File created: ${name}`);
} catch (err: unknown) {
if (isErrnoException(err) && err.code === 'ENOENT') {
this.output.debug(`File created, but has since been deleted: ${name}`);
fileRemoved(name, this.files, changed, removed);
} else {
throw err;
}
}
}
handleFileDeleted(
fsPath: string,
changed: Set<string>,
removed: Set<string>
): void {
const name = relative(this.cwd, fsPath);
this.output.debug(`File deleted: ${name}`);
fileRemoved(name, this.files, changed, removed);
const extensionless = this.getExtensionlessFile(name);
if (extensionless) {
this.output.debug(`File deleted: ${extensionless}`);
fileRemoved(extensionless, this.files, changed, removed);
}
}
async handleFileModified(
fsPath: string,
changed: Set<string>,
removed: Set<string>
): Promise<void> {
const name = relative(this.cwd, fsPath);
try {
this.files[name] = await FileFsRef.fromFsPath({ fsPath });
fileChanged(name, changed, removed);
this.output.debug(`File modified: ${name}`);
} catch (err: unknown) {
if (isErrnoException(err) && err.code === 'ENOENT') {
this.output.debug(`File modified, but has since been deleted: ${name}`);
fileRemoved(name, this.files, changed, removed);
} else {
throw err;
}
}
}
async updateBuildMatches(
vercelConfig: VercelConfig,
isInitial = false
): Promise<void> {
const fileList = this.resolveBuildFiles(this.files);
const matches = await getBuildMatches(
vercelConfig,
this.cwd,
this.output,
this,
fileList
);
const sources = matches.map(m => m.src);
if (isInitial && fileList.length === 0) {
this.output.warn('There are no files inside your deployment.');
}
// Delete build matches that no longer exists
const ops: Promise<void>[] = [];
for (const src of this.buildMatches.keys()) {
if (!sources.includes(src)) {
this.output.debug(`Removing build match for "${src}"`);
const match = this.buildMatches.get(src);
if (match) {
ops.push(shutdownBuilder(match, this.output));
}
this.buildMatches.delete(src);
}
}
await Promise.all(ops);
// Add the new matches to the `buildMatches` map
const blockingBuilds: Promise<void>[] = [];
for (const match of matches) {
const currentMatch = this.buildMatches.get(match.src);
if (!buildMatchEquals(currentMatch, match)) {
this.output.debug(
`Adding build match for "${match.src}" with "${match.use}"`
);
this.buildMatches.set(match.src, match);
if (!isInitial && needsBlockingBuild(match)) {
const buildPromise = executeBuild(
vercelConfig,
this,
this.files,
match,
null,
false
);
blockingBuilds.push(buildPromise);
}
}
}
if (blockingBuilds.length > 0) {
this.output.debug(
`Waiting for ${blockingBuilds.length} "blocking builds"`
);
this.blockingBuildsPromise = Promise.all(blockingBuilds)
.then(() => {
this.output.debug(
`Cleaning up "blockingBuildsPromise" after successful resolve`
);
this.blockingBuildsPromise = null;
})
.catch((err?: Error) => {
this.output.debug(
`Cleaning up "blockingBuildsPromise" after error: ${err}`
);
this.blockingBuildsPromise = null;
if (err) {
this.output.prettyError(err);
}
});
}
// Sort build matches to make sure `@vercel/static-build` is always last
this.buildMatches = new Map(
[...this.buildMatches.entries()].sort((matchA, matchB) => {
return sortBuilders(matchA[1] as Builder, matchB[1] as Builder);
})
);
}
async getLocalEnv(fileName: string, base?: Env): Promise<Env> {
// TODO: use the file watcher to only invalidate the env `dotfile`
// once a change to the `fileName` occurs
const filePath = join(this.cwd, fileName);
let env: Env = {};
try {
const dotenv = await fs.readFile(filePath, 'utf8');
this.output.debug(`Using local env: ${filePath}`);
env = parseDotenv(dotenv);
env = this.injectSystemValuesInDotenv(env);
} catch (err: unknown) {
if (!isErrnoException(err) || err.code !== 'ENOENT') {
throw err;
}
}
try {
return {
...this.validateEnvConfig(fileName, base || {}, env),
};
} catch (err) {
if (err instanceof MissingDotenvVarsError) {
this.output.error(err.message);
await this.exit();
} else {
throw err;
}
}
return {};
}
clearVercelConfigPromise = () => {
this.getVercelConfigPromise = null;
};
getVercelConfig(): Promise<VercelConfig> {
if (this.getVercelConfigPromise) {
return this.getVercelConfigPromise;
}
this.getVercelConfigPromise = this._getVercelConfig();
// Clean up the promise once it has resolved
const clear = this.clearVercelConfigPromise;
this.getVercelConfigPromise.finally(clear);
return this.getVercelConfigPromise;
}
get devCommand() {
if (this.projectSettings?.devCommand) {
return this.projectSettings.devCommand;
} else if (this.projectSettings?.framework) {
const frameworkSlug = this.projectSettings.framework;
const framework = frameworkList.find(f => f.slug === frameworkSlug);
if (framework) {
const defaults = framework.settings.devCommand.value;
if (defaults) {
return defaults;
}
}
}
return undefined;
}
async _getVercelConfig(): Promise<VercelConfig> {
const configPath = getVercelConfigPath(this.cwd);
const [
pkg = null,
// The default empty `vercel.json` is used to serve all
// files as static when no `vercel.json` is present
vercelConfig = { version: 2, [fileNameSymbol]: 'vercel.json' },
] = await Promise.all([
this.readJsonFile<PackageJson>('package.json'),
this.readJsonFile<VercelConfig>(configPath),
]);
await this.validateVercelConfig(vercelConfig);
this.projectSettings = {
...this.originalProjectSettings,
...pickOverrides(vercelConfig),
};
const { error: routeError, routes: maybeRoutes } =
getTransformedRoutes(vercelConfig);
if (routeError) {
this.output.prettyError(routeError);
await this.exit();
}
vercelConfig.routes = maybeRoutes || [];
// no builds -> zero config
if (!vercelConfig.builds || vercelConfig.builds.length === 0) {
const featHandleMiss = true; // enable for zero config
const { projectSettings, cleanUrls, trailingSlash } = vercelConfig;
const opts = { output: this.output };
const files = (await getFiles(this.cwd, opts)).map(f =>
relative(this.cwd, f)
);
let {
builders,
warnings,
errors,
defaultRoutes,
redirectRoutes,
rewriteRoutes,
errorRoutes,
} = await detectBuilders(files, pkg, {
tag: getDistTag(cliPkg.version) === 'canary' ? 'canary' : 'latest',
functions: vercelConfig.functions,
projectSettings: projectSettings || this.projectSettings,
featHandleMiss,
cleanUrls,
trailingSlash,
});
if (errors) {
this.output.error(errors[0].message);
await this.exit();
}
if (warnings?.length > 0) {
warnings.forEach(warning =>
this.output.warn(warning.message, null, warning.link, warning.action)
);
}
if (builders) {
if (this.devCommand) {
builders = builders.filter(filterFrontendBuilds);
}
vercelConfig.builds = vercelConfig.builds || [];
vercelConfig.builds.push(...builders);
delete vercelConfig.functions;
}
let routes: Route[] = [];
routes.push(...(redirectRoutes || []));
routes.push(
...appendRoutesToPhase({
routes: vercelConfig.routes,
newRoutes: rewriteRoutes,
phase: 'filesystem',
})
);
routes = appendRoutesToPhase({
routes,
newRoutes: errorRoutes,
phase: 'error',
});
routes.push(...(defaultRoutes || []));
vercelConfig.routes = routes;
}
if (Array.isArray(vercelConfig.builds)) {
if (this.devCommand) {
vercelConfig.builds = vercelConfig.builds.filter(filterFrontendBuilds);
}
// `@vercel/static-build` needs to be the last builder
// since it might catch all other requests
vercelConfig.builds.sort(sortBuilders);
}
await this.validateVercelConfig(vercelConfig);
// TODO: temporarily strip and warn since `has` is not implemented yet
vercelConfig.routes = (vercelConfig.routes || []).filter(route => {
if ('has' in route) {
if (!this.vercelConfigWarning) {
this.vercelConfigWarning = true;
this.output.warn(
`The "has" property in ${vercelConfig[fileNameSymbol]} will be ignored during development. Deployments will work as expected.`
);
}
return false;
}
return true;
});
this.caseSensitive = hasNewRoutingProperties(vercelConfig);
this.apiDir = detectApiDirectory(vercelConfig.builds || []);
this.apiExtensions = detectApiExtensions(vercelConfig.builds || []);
// Update the env vars configuration
let [runEnv, buildEnv] = await Promise.all([
this.getLocalEnv('.env', vercelConfig.env),
this.getLocalEnv('.env.build', vercelConfig.build?.env),
]);
let allEnv = { ...buildEnv, ...runEnv };
// If no .env/.build.env is present, use cloud environment variables
if (Object.keys(allEnv).length === 0) {
const envValues = { ...this.envValues };
if (this.address.host) {
envValues['VERCEL_URL'] = this.address.host;
}
allEnv = { ...envValues };
runEnv = { ...envValues };
buildEnv = { ...envValues };
}
// legacy NOW_REGION env variable
runEnv['NOW_REGION'] = 'dev1';
buildEnv['NOW_REGION'] = 'dev1';
allEnv['NOW_REGION'] = 'dev1';
// mirror how VERCEL_REGION is injected in prod/preview
// only inject in `runEnvs`, because `allEnvs` is exposed to dev command
// and should not contain VERCEL_REGION
if (this.projectSettings?.autoExposeSystemEnvs) {
runEnv['VERCEL_REGION'] = 'dev1';
}
this.envConfigs = { buildEnv, runEnv, allEnv };
// If the `devCommand` was modified via project settings
// overrides then the dev process needs to be restarted
await this.runDevCommand();
return vercelConfig;
}
async readJsonFile<T>(
filePath: string
): Promise<WithFileNameSymbol<T> | void> {
let rel, abs;
if (isAbsolute(filePath)) {
rel = path.relative(this.cwd, filePath);
abs = filePath;
} else {
rel = filePath;
abs = join(this.cwd, filePath);
}
this.output.debug(`Reading \`${rel}\` file`);
try {
const raw = await fs.readFile(abs, 'utf8');
const parsed: WithFileNameSymbol<T> = JSON.parse(raw);
parsed[fileNameSymbol] = rel;
return parsed;
} catch (err: unknown) {
if (isError(err)) {
if (isErrnoException(err) && err.code === 'ENOENT') {
this.output.debug(`No \`${rel}\` file present`);
} else if (err.name === 'SyntaxError') {
this.output.warn(
`There is a syntax error in the \`${rel}\` file: ${err.message}`
);
}
} else {
throw err;
}
}
}
async tryValidateOrExit(
config: VercelConfig,
validate: (c: VercelConfig) => string | null
): Promise<void> {
const message = validate(config);
if (message) {
this.output.error(message);
await this.exit(1);
}
}
async validateVercelConfig(config: VercelConfig): Promise<void> {
if (config.version === 1) {
this.output.error('Cannot run `version: 1` projects.');
await this.exit(1);
return;
}
const error = validateConfig(config);
if (error) {
this.output.prettyError(error);
await this.exit(1);
}
}
validateEnvConfig(type: string, env: Env = {}, localEnv: Env = {}): Env {
// Validate if there are any missing env vars defined in `vercel.json`,
// but not in the `.env` / `.build.env` file
const missing: string[] = Object.entries(env)
.filter(
([name, value]) =>
typeof value === 'string' &&
value.startsWith('@') &&
!hasOwnProperty(localEnv, name)
)
.map(([name]) => name);
if (missing.length > 0) {
throw new MissingDotenvVarsError(type, missing);
}
const merged: Env = { ...env, ...localEnv };
// Validate that the env var name matches what AWS Lambda allows:
// - https://docs.aws.amazon.com/lambda/latest/dg/env_variables.html
let hasInvalidName = false;
for (const key of Object.keys(merged)) {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
this.output.warn(
`Ignoring ${type
.split('.')
.slice(1)
.reverse()
.join(' ')} var ${JSON.stringify(key)} because name is invalid`
);
hasInvalidName = true;
delete merged[key];
}
}
if (hasInvalidName) {
this.output.log(
'Env var names must start with letters, and can only contain alphanumeric characters and underscores'
);
}
return merged;
}
injectSystemValuesInDotenv(env: Env): Env {
for (const name of Object.keys(env)) {
if (name === 'VERCEL_URL') {
env['VERCEL_URL'] = this.address.host;
} else if (name === 'VERCEL_REGION') {
env['VERCEL_REGION'] = 'dev1';
}
}
return env;
}
/**
* Create an array of from builder inputs
* and filter them
*/
resolveBuildFiles(files: BuilderInputs) {
return Object.keys(files).filter(this.filter);
}
start(...listenSpec: ListenSpec): Promise<void> {
if (!this.startPromise) {
this.startPromise = this._start(...listenSpec).catch(err => {
this.stop();
throw err;
});
}
return this.startPromise;
}
/**
* Launches the `vercel dev` server.
*/
async _start(...listenSpec: ListenSpec): Promise<void> {
if (!fs.existsSync(this.cwd)) {
throw new Error(`${chalk.bold(this.cwd)} doesn't exist`);
}
if (!fs.lstatSync(this.cwd).isDirectory()) {
throw new Error(`${chalk.bold(this.cwd)} is not a directory`);
}
const { ig } = await getVercelIgnore(this.cwd);
this.filter = ig.createFilter();
let address: string | null = null;
while (typeof address !== 'string') {
try {
address = await listen(this.server, ...listenSpec);
} catch (err: unknown) {
if (isErrnoException(err)) {
this.output.debug(`Got listen error: ${err.code}`);
if (err.code === 'EADDRINUSE') {
if (typeof listenSpec[0] === 'number') {
// Increase port and try again
this.output.note(
`Requested port ${chalk.yellow(
String(listenSpec[0])
)} is already in use`
);
listenSpec[0]++;
} else {
this.output.error(
`Requested socket ${chalk.cyan(
listenSpec[0]
)} is already in use`
);
process.exit(1);
}
}
} else {
throw err;
}
}
}
this._address = new URL(replaceLocalhost(address));
const vercelConfig = await this.getVercelConfig();
const devCommandPromise = this.runDevCommand();
const files = await getFiles(this.cwd, { output: this.output });
this.files = {};
for (const fsPath of files) {
let path = relative(this.cwd, fsPath);
const { mode } = await fs.stat(fsPath);
this.files[path] = new FileFsRef({ mode, fsPath });
const extensionless = this.getExtensionlessFile(path);
if (extensionless) {
this.files[extensionless] = new FileFsRef({ mode, fsPath });
}
}
await this.updateBuildMatches(vercelConfig, true);
// Builders that do not define a `shouldServe()` function need to be
// executed at boot-up time in order to get the initial assets and/or routes
// that can be served by the builder.
const blockingBuilds = Array.from(this.buildMatches.values()).filter(
needsBlockingBuild
);
if (blockingBuilds.length > 0) {
this.output.log(
`Creating initial ${plural('build', blockingBuilds.length)}`
);
for (const match of blockingBuilds) {
await executeBuild(vercelConfig, this, this.files, match, null, true);
}
this.output.success('Build completed');
}
// Ensure that the dev cache directory exists so that runtimes
// don't need to create it themselves.
await fs.mkdirp(this.devCacheDir);
// Start the filesystem watcher
this.watcher = watch(this.cwd, {
ignored: (path: string) => !this.filter(path),
ignoreInitial: true,
useFsEvents: false,
usePolling: false,
persistent: true,
});
this.watcher.on('add', (path: string) => {
this.enqueueFsEvent('add', path);
});
this.watcher.on('change', (path: string) => {
this.enqueueFsEvent('change', path);
});
this.watcher.on('unlink', (path: string) => {
this.enqueueFsEvent('unlink', path);
});
this.watcher.on('error', (err: Error) => {
this.output.error(`Watcher error: ${err}`);
});
// Wait for "ready" event of the watcher
await once(this.watcher, 'ready');
// Configure the server to forward WebSocket "upgrade" events to the proxy.
this.server.on('upgrade', async (req, socket, head) => {
await this.startPromise;
if (!this.devProcessOrigin) {
this.output.debug(
`Detected "upgrade" event, but closing socket because no frontend dev server is running`
);
socket.destroy();
return;
}
const target = this.devProcessOrigin;
this.output.debug(`Detected "upgrade" event, proxying to ${target}`);
this.proxy.ws(req, socket, head, { target });
});
await devCommandPromise;
let addressFormatted = this.address.toString();
if (this.address.pathname === '/' && this.address.protocol === 'http:') {
// log address without trailing slash to maintain backwards compatibility
addressFormatted = addressFormatted.replace(/\/$/, '');
}
this.output.ready(`Available at ${link(addressFormatted)}`);
}
/**
* Shuts down the `vercel dev` server, and cleans up any temporary resources.
*/
async stop(exitCode?: number): Promise<void> {
if (this.stopping) return;
this.stopping = true;
const { devProcess } = this;
const { debug } = this.output;
const ops: Promise<any>[] = [];
for (const match of this.buildMatches.values()) {
ops.push(shutdownBuilder(match, this.output));
}
if (devProcess) {
ops.push(treeKill(devProcess.pid));
}
ops.push(close(this.server));
if (this.watcher) {
debug(`Closing file watcher`);
ops.push(this.watcher.close());
}
for (const pid of this.devServerPids) {
ops.push(this.killBuilderDevServer(pid));
}
try {
await Promise.all(ops);
} catch (err: unknown) {
if (isErrnoException(err) && err.code === 'ERR_SERVER_NOT_RUNNING') {
process.exit(exitCode || 0);
} else {
throw err;
}
}
}
async killBuilderDevServer(pid: number) {
const { debug } = this.output;
debug(`Killing builder dev server with PID ${pid}`);
this.devServerPids.delete(pid);
try {
await treeKill(pid);
debug(`Killed builder dev server with PID ${pid}`);
} catch (err) {
debug(`Failed to kill builder dev server with PID ${pid}: ${err}`);
}
}
async send404(
req: http.IncomingMessage,
res: http.ServerResponse,
requestId: string
): Promise<void> {
return this.sendError(req, res, requestId, 'NOT_FOUND', 404);
}
async sendError(
req: http.IncomingMessage,
res: http.ServerResponse,
requestId: string,
errorCode?: string,
statusCode: number = 500,
headers: HttpHeadersConfig = {}
): Promise<void> {
res.statusCode = statusCode;
this.setResponseHeaders(res, requestId, headers);
const http_status_description = generateHttpStatusDescription(statusCode);
const error_code = errorCode || http_status_description;
const errorMessage = generateErrorMessage(statusCode, error_code);
let body: string;
const { accept = 'text/plain' } = req.headers;
if (accept.includes('json')) {
res.setHeader('content-type', 'application/json');
const json = JSON.stringify({
error: {
code: statusCode,
message: errorMessage.title,
},
});
body = `${json}\n`;
} else if (accept.includes('html')) {
res.setHeader('content-type', 'text/html; charset=utf-8');
let view: string;
if (statusCode === 404) {
view = errorTemplate404({
...errorMessage,
http_status_code: statusCode,
http_status_description,
error_code,
request_id: requestId,
});
} else if (statusCode === 502) {
view = errorTemplate502({
...errorMessage,
http_status_code: statusCode,
http_status_description,
error_code,
request_id: requestId,
});
} else {
view = errorTemplate({
http_status_code: statusCode,
http_status_description,
error_code,
request_id: requestId,
});
}
body = errorTemplateBase({
http_status_code: statusCode,
http_status_description,
view,
});
} else {
res.setHeader('content-type', 'text/plain; charset=utf-8');
body = `${errorMessage.title}\n\n${error_code}\n`;
}
res.end(body);
}
async sendRedirect(
req: http.IncomingMessage,
res: http.ServerResponse,
requestId: string,
location: string,
statusCode: number = 302
): Promise<void> {
this.output.debug(`Redirect ${statusCode}: ${location}`);
res.statusCode = statusCode;
this.setResponseHeaders(res, requestId, { location });
let body: string;
const { accept = 'text/plain' } = req.headers;
if (accept.includes('json')) {
res.setHeader('content-type', 'application/json');
const json = JSON.stringify({
redirect: location,
status: String(statusCode),
});
body = `${json}\n`;
} else if (accept.includes('html')) {
res.setHeader('content-type', 'text/html; charset=utf-8');
body = redirectTemplate({ location, statusCode });
} else {
res.setHeader('content-type', 'text/plain; charset=utf-8');
body = `Redirecting to ${location} (${statusCode})\n`;
}
res.end(body);
}
getRequestIp(req: http.IncomingMessage): string {
// TODO: respect the `x-forwarded-for` headers
return req.connection.remoteAddress || '127.0.0.1';
}
/**
* Sets the response `headers` including the platform headers to `res`.
*/
setResponseHeaders(
res: http.ServerResponse,
requestId: string,
headers: http.OutgoingHttpHeaders = {}
): void {
const allHeaders = {
'cache-control': 'public, max-age=0, must-revalidate',
...headers,
server: 'Vercel',
'x-vercel-id': requestId,
'x-vercel-cache': 'MISS',
};
for (const [name, value] of Object.entries(allHeaders)) {
res.setHeader(name, value);
}
}
/**
* Returns the request `headers` that will be sent to the Lambda.
*/
getProxyHeaders(
req: http.IncomingMessage,
requestId: string,
xfwd: boolean
): http.IncomingHttpHeaders {
const ip = this.getRequestIp(req);
const { host } = req.headers;
const headers: http.IncomingHttpHeaders = {
connection: 'close',
'x-real-ip': ip,
'x-vercel-deployment-url': host,
'x-vercel-forwarded-for': ip,
'x-vercel-id': requestId,
};
if (xfwd) {
headers['x-forwarded-host'] = host;
headers['x-forwarded-proto'] = 'http';
headers['x-forwarded-for'] = ip;
}
return headers;
}
async triggerBuild(
match: BuildMatch,
requestPath: string | null,
req: http.IncomingMessage | null,
vercelConfig: VercelConfig,
previousBuildResult?: BuildResult,
filesChanged?: string[],
filesRemoved?: string[]
) {
// If the requested asset wasn't found in the match's
// outputs then trigger a build
const buildKey =
requestPath === null
? match.entrypoint
: `${match.entrypoint}-${requestPath}`;
let buildPromise = this.inProgressBuilds.get(buildKey);
if (buildPromise) {
// A build for `buildKey` is already in progress, so don't trigger
// another rebuild for this request - just wait on the existing one.
let msg = `De-duping build "${buildKey}"`;
if (req) {
msg += ` for "${req.method} ${req.url}"`;
}
this.output.debug(msg);
} else {
if (previousBuildResult) {
// Tear down any `output` assets from a previous build, so that they
// are not available to be served while the rebuild is in progress.
for (const [name] of Object.entries(previousBuildResult.output)) {
this.output.debug(`Removing asset "${name}"`);
delete match.buildOutput[name];
// TODO: shut down Lambda instance
}
}
let msg = `Building asset "${buildKey}"`;
if (req) {
msg += ` for "${req.method} ${req.url}"`;
}
this.output.debug(msg);
buildPromise = executeBuild(
vercelConfig,
this,
this.files,
match,
requestPath,
false,
filesChanged,
filesRemoved
);
this.inProgressBuilds.set(buildKey, buildPromise);
}
try {
await buildPromise;
} finally {
this.output.debug(`Built asset ${buildKey}`);
this.inProgressBuilds.delete(buildKey);
}
}
getExtensionlessFile = (path: string) => {
const ext = extname(path);
if (
this.apiDir &&
path.startsWith(this.apiDir + '/') &&
this.apiExtensions.has(ext)
) {
// lambda function files are trimmed of their file extension
return path.slice(0, -ext.length);
}
return null;
};
/**
* DevServer HTTP handler
*/
devServerHandler: HttpHandler = async (
req: http.IncomingMessage,
res: http.ServerResponse
) => {
await this.startPromise;
let requestId = generateRequestId(this.podId);
if (this.stopping) {
res.setHeader('Connection', 'close');
await this.send404(req, res, requestId);
return;
}
const method = req.method || 'GET';
this.output.debug(`${chalk.bold(method)} ${req.url}`);
try {
const vercelConfig = await this.getVercelConfig();
await this.serveProjectAsNowV2(req, res, requestId, vercelConfig);
} catch (err: unknown) {
console.error(err);
if (isError(err) && typeof err.stack === 'string') {
this.output.debug(err.stack);
}
if (!res.finished) {
res.statusCode = 500;
res.end(errorToString(err));
}
}
};
/**
* This is the equivalent to now-proxy exit_with_status() function.
*/
exitWithStatus = async (
match: BuildMatch | null,
routeResult: RouteResult,
phase: HandleValue | null,
req: http.IncomingMessage,
res: http.ServerResponse,
requestId: string
): Promise<boolean> => {
const { status, headers, dest } = routeResult;
const location = headers['location'] || dest;
if (status && location && 300 <= status && status <= 399) {
this.output.debug(`Route found with redirect status code ${status}`);
await this.sendRedirect(req, res, requestId, location, status);
return true;
}
if (!match && status && phase !== 'miss') {
this.output.debug(`Route found with with status code ${status}`);
await this.sendError(req, res, requestId, '', status, headers);
return true;
}
return false;
};
/**
* Serve project directory as a v2 deployment.
*/
serveProjectAsNowV2 = async (
req: http.IncomingMessage,
res: http.ServerResponse,
requestId: string,
vercelConfig: VercelConfig,
routes: Route[] | undefined = vercelConfig.routes,
callLevel: number = 0
) => {
const { debug } = this.output;
// If there is a double-slash present in the URL,
// then perform a redirect to make it "clean".
const parsed = url.parse(req.url || '/');
if (typeof parsed.pathname === 'string' && parsed.pathname.includes('//')) {
let location = parsed.pathname.replace(/\/+/g, '/');
if (parsed.search) {
location += parsed.search;
}
// Only `GET` requests are redirected.
// Other methods are normalized without redirecting.
if (req.method === 'GET') {
await this.sendRedirect(req, res, requestId, location, 301);
return;
}
debug(`Rewriting URL from "${req.url}" to "${location}"`);
req.url = location;
}
if (callLevel === 0) {
await this.updateBuildMatches(vercelConfig);
}
if (this.blockingBuildsPromise) {
debug('Waiting for builds to complete before handling request');
await this.blockingBuildsPromise;
}
const getReqUrl = (rr: RouteResult): string | undefined => {
if (rr.dest) {
if (rr.query) {
const destParsed = url.parse(rr.dest);
const destQuery = parseQueryString(destParsed.search);
Object.assign(destQuery, rr.query);
destParsed.search = formatQueryString(destQuery);
return url.format(destParsed);
}
return rr.dest;
}
return req.url;
};
const handleMap = getRoutesTypes(routes);
const missRoutes = handleMap.get('miss') || [];
const hitRoutes = handleMap.get('hit') || [];
const errorRoutes = handleMap.get('error') || [];
const filesystemRoutes = handleMap.get('filesystem') || [];
const phases: (HandleValue | null)[] = [null, 'filesystem'];
let routeResult: RouteResult | null = null;
let match: BuildMatch | null = null;
let statusCode: number | undefined;
let prevUrl = req.url;
let prevHeaders: HttpHeadersConfig = {};
let middlewarePid: number | undefined;
// Run the middleware file, if present, and apply any
// mutations to the incoming request based on the
// result of the middleware invocation.
const middleware = [...this.buildMatches.values()].find(
m => m.config?.middleware === true
);
if (middleware) {
let startMiddlewareResult: StartDevServerResult | undefined;
// TODO: can we add some caching to prevent (re-)starting
// the middleware server for every HTTP request?
const { envConfigs, files, devCacheDir, cwd: workPath } = this;
try {
const { builder } = middleware.builderWithPkg;
if (builder.version === 3) {
startMiddlewareResult = await builder.startDevServer?.({
files,
entrypoint: middleware.entrypoint,
workPath,
repoRootPath: this.cwd,
config: middleware.config || {},
meta: {
isDev: true,
devCacheDir,
requestUrl: req.url,
env: { ...envConfigs.runEnv },
buildEnv: { ...envConfigs.buildEnv },
},
});
}
if (startMiddlewareResult) {
const { port, pid } = startMiddlewareResult;
middlewarePid = pid;
this.devServerPids.add(pid);
const middlewareReqHeaders = nodeHeadersToFetchHeaders(req.headers);
// Add the Vercel platform proxy request headers
const proxyHeaders = this.getProxyHeaders(req, requestId, true);
for (const [name, value] of nodeHeadersToFetchHeaders(proxyHeaders)) {
middlewareReqHeaders.set(name, value);
}
const middlewareRes = await fetch(
`http://127.0.0.1:${port}${parsed.path}`,
{
headers: middlewareReqHeaders,
method: req.method,
redirect: 'manual',
}
);
const middlewareBody = await middlewareRes.buffer();
if (middlewareRes.status === 500 && middlewareBody.byteLength === 0) {
await this.sendError(
req,
res,
requestId,
'EDGE_FUNCTION_INVOCATION_FAILED',
500
);
return;
}
// Apply status code from middleware invocation,
// for i.e. redirects or a custom 404 page
res.statusCode = middlewareRes.status;
let rewritePath = '';
let contentType = '';
let shouldContinue = false;
const skipMiddlewareHeaders = new Set([
'date',
'connection',
'content-length',
'transfer-encoding',
]);
applyOverriddenHeaders(req.headers, middlewareRes.headers);
for (const [name, value] of middlewareRes.headers) {
if (name === 'x-middleware-next') {
shouldContinue = value === '1';
} else if (name === 'x-middleware-rewrite') {
rewritePath = value;
shouldContinue = true;
} else if (name === 'content-type') {
contentType = value;
} else if (!skipMiddlewareHeaders.has(name)) {
// Any other kind of response header should be included
// on both the incoming HTTP request (for when proxying
// to another function) and the outgoing HTTP response.
res.setHeader(name, value);
req.headers[name] = value;
}
}
if (!shouldContinue) {
this.setResponseHeaders(res, requestId);
if (middlewareBody.length > 0) {
res.setHeader('content-length', middlewareBody.length);
if (contentType) {
res.setHeader('content-type', contentType);
}
res.end(middlewareBody);
} else {
res.end();
}
return;
}
if (rewritePath) {
debug(`Detected rewrite path from middleware: "${rewritePath}"`);
prevUrl = rewritePath;
const beforeRewriteUrl = req.url || '/';
if (isURL(rewritePath)) {
const rewriteUrlParsed = new URL(rewritePath);
// `this.address` already has localhost normalized from ip4 and ip6 values
if (this.address.origin === rewriteUrlParsed.origin) {
// remove origin, leaving the path
req.url =
rewritePath.slice(rewriteUrlParsed.origin.length) || '/';
prevUrl = req.url;
} else {
// Proxy to absolute URL with different origin
debug(`ProxyPass: ${rewritePath}`);
this.setResponseHeaders(res, requestId);
proxyPass(req, res, rewritePath, this, requestId);
return;
}
} else {
// Retain orginal pathname, but override query parameters from the rewrite
const rewriteUrlParsed = url.parse(beforeRewriteUrl);
rewriteUrlParsed.search = url.parse(rewritePath).search;
req.url = url.format(rewriteUrlParsed);
}
debug(
`Rewrote incoming HTTP URL from "${beforeRewriteUrl}" to "${req.url}"`
);
}
}
} catch (err: unknown) {
// `startDevServer()` threw an error. Most likely this means the dev
// server process exited before sending the port information message
// (missing dependency at runtime, for example).
if (isSpawnError(err) && err.code === 'ENOENT') {
err.message = `Command not found: ${chalk.cyan(
err.path,
...err.spawnargs
)}\nPlease ensure that ${cmd(err.path!)} is properly installed`;
(err as any).link = 'https://vercel.link/command-not-found';
}
this.output.prettyError(err);
await this.sendError(
req,
res,
requestId,
'EDGE_FUNCTION_INVOCATION_FAILED',
500
);
return;
} finally {
if (middlewarePid) {
this.killBuilderDevServer(middlewarePid);
}
}
}
for (const phase of phases) {
statusCode = undefined;
const phaseRoutes = handleMap.get(phase) || [];
routeResult = await devRouter(
prevUrl,
req.method,
phaseRoutes,
this,
vercelConfig,
prevHeaders,
missRoutes,
phase
);
if (routeResult.continue) {
if (routeResult.dest) {
prevUrl = getReqUrl(routeResult);
}
if (routeResult.headers) {
prevHeaders = routeResult.headers;
}
}
if (routeResult.isDestUrl) {
// Mix the `routes` result dest query params into the req path
const destParsed = url.parse(routeResult.dest);
const destQuery = parseQueryString(destParsed.search);
Object.assign(destQuery, routeResult.query);
destParsed.search = formatQueryString(destQuery);
const destUrl = url.format(destParsed);
debug(`ProxyPass: ${destUrl}`);
this.setResponseHeaders(res, requestId);
return proxyPass(req, res, destUrl, this, requestId);
}
match = await findBuildMatch(
this.buildMatches,
this.files,
routeResult.dest,
this,
vercelConfig
);
if (
await this.exitWithStatus(
match,
routeResult,
phase,
req,
res,
requestId
)
) {
return;
}
if (!match && missRoutes.length > 0) {
// Since there was no build match, enter the miss phase
routeResult = await devRouter(
getReqUrl(routeResult),
req.method,
missRoutes,
this,
vercelConfig,
routeResult.headers,
[],
'miss'
);
match = await findBuildMatch(
this.buildMatches,
this.files,
routeResult.dest,
this,
vercelConfig
);
if (
await this.exitWithStatus(
match,
routeResult,
phase,
req,
res,
requestId
)
) {
return;
}
} else if (match && hitRoutes.length > 0) {
// Since there was a build match, enter the hit phase.
// The hit phase must not set status code.
const prevStatus = routeResult.status;
routeResult = await devRouter(
getReqUrl(routeResult),
req.method,
hitRoutes,
this,
vercelConfig,
routeResult.headers,
[],
'hit'
);
routeResult.status = prevStatus;
}
statusCode = routeResult.status;
if (match) {
// end the phase
break;
}
if (phase === null && filesystemRoutes.length === 0) {
// hack to skip the reset from null to filesystem
break;
}
}
if (!match && routeResult && errorRoutes.length > 0) {
// error phase
const routeResultForError = await devRouter(
getReqUrl(routeResult),
req.method,
errorRoutes,
this,
vercelConfig,
routeResult.headers,
[],
'error'
);
const { matched_route } = routeResultForError;
const matchForError = await findBuildMatch(
this.buildMatches,
this.files,
routeResultForError.dest,
this,
vercelConfig
);
if (matchForError) {
debug(`Route match detected in error phase, breaking loop`);
routeResult = routeResultForError;
statusCode = routeResultForError.status;
match = matchForError;
} else if (matched_route && matched_route.src && !matched_route.dest) {
debug(
'Route without `dest` detected in error phase, attempting to exit early'
);
if (
await this.exitWithStatus(
matchForError,
routeResultForError,
'error',
req,
res,
requestId
)
) {
return;
}
}
}
if (!routeResult) {
throw new Error('Expected Route Result but none was found.');
}
const { dest, query, headers } = routeResult;
// Set any headers defined in the matched `route` config
for (const [name, value] of Object.entries(headers)) {
res.setHeader(name, value);
}
if (statusCode) {
// Set the `statusCode` as read-only so that `http-proxy`
// is not able to modify the value in the future
Object.defineProperty(res, 'statusCode', {
get() {
return statusCode;
},
/* eslint-disable @typescript-eslint/no-unused-vars */
set(_: number) {
/* ignore */
},
});
}
const requestPath = dest.replace(/^\//, '');
if (!match) {
// If the dev command is started, then proxy to it
if (this.devProcessOrigin) {
const upstream = this.devProcessOrigin;
debug(`Proxying to frontend dev server: ${upstream}`);
// Add the Vercel platform proxy request headers
const headers = this.getProxyHeaders(req, requestId, false);
for (const [name, value] of Object.entries(headers)) {
req.headers[name] = value;
}
this.setResponseHeaders(res, requestId);
const origUrl = url.parse(req.url || '/');
const origQuery = parseQueryString(origUrl.search);
origUrl.pathname = dest;
Object.assign(origQuery, query);
origUrl.search = formatQueryString(origQuery);
req.url = url.format(origUrl);
return proxyPass(req, res, upstream, this, requestId, false);
}
if (
(statusCode === 404 && routeResult.phase === 'miss') ||
!this.renderDirectoryListing(req, res, requestPath, requestId)
) {
await this.send404(req, res, requestId);
}
return;
}
const buildRequestPath = match.buildResults.has(null) ? null : requestPath;
const buildResult = match.buildResults.get(buildRequestPath);
if (
buildResult &&
Array.isArray(buildResult.routes) &&
buildResult.routes.length > 0
) {
const origUrl = url.parse(req.url || '/');
const origQuery = parseQueryString(origUrl.search);
origUrl.pathname = dest;
Object.assign(origQuery, query);
origUrl.search = formatQueryString(origQuery);
const newUrl = url.format(origUrl);
debug(
`Checking build result's ${buildResult.routes.length} \`routes\` to match ${newUrl}`
);
const matchedRoute = await devRouter(
newUrl,
req.method,
buildResult.routes,
this,
vercelConfig
);
if (matchedRoute.found && callLevel === 0) {
debug(`Found matching route ${matchedRoute.dest} for ${newUrl}`);
req.url = newUrl;
await this.serveProjectAsNowV2(
req,
res,
requestId,
vercelConfig,
buildResult.routes,
callLevel + 1
);
return;
}
}
// Before doing any asset matching, check if this builder supports the
// `startDevServer()` "optimization". In this case, the vercel dev server invokes
// `startDevServer()` on the builder for every HTTP request so that it boots
// up a single-serve dev HTTP server that vercel dev will proxy this HTTP request
// to. Once the proxied request is finished, vercel dev shuts down the dev
// server child process.
const { builder, pkg: builderPkg } = match.builderWithPkg;
if (builder.version === 3 && typeof builder.startDevServer === 'function') {
let devServerResult: StartDevServerResult = null;
try {
const { envConfigs, files, devCacheDir, cwd: workPath } = this;
devServerResult = await builder.startDevServer({
files,
entrypoint: match.entrypoint,
workPath,
config: match.config || {},
repoRootPath: this.cwd,
meta: {
isDev: true,
requestPath,
devCacheDir,
env: {
...envConfigs.runEnv,
VERCEL_DEBUG_PREFIX: this.output.debugEnabled
? '[builder]'
: undefined,
},
buildEnv: { ...envConfigs.buildEnv },
},
});
} catch (err: unknown) {
// `startDevServer()` threw an error. Most likely this means the dev
// server process exited before sending the port information message
// (missing dependency at runtime, for example).
if (isSpawnError(err) && err.code === 'ENOENT') {
err.message = `Command not found: ${chalk.cyan(
err.path,
...err.spawnargs
)}\nPlease ensure that ${cmd(err.path!)} is properly installed`;
(err as any).link = 'https://vercel.link/command-not-found';
}
this.output.prettyError(err);
await this.sendError(
req,
res,
requestId,
'NO_RESPONSE_FROM_FUNCTION',
502
);
return;
}
if (devServerResult) {
// When invoking lambda functions, the region where the lambda was invoked
// is also included in the request ID. So use the same `dev1` fake region.
requestId = generateRequestId(this.podId, true);
const { port, pid } = devServerResult;
this.devServerPids.add(pid);
res.once('close', () => {
this.killBuilderDevServer(pid);
});
debug(
`Proxying to "${builderPkg.name}" dev server (port=${port}, pid=${pid})`
);
// Mix in the routing based query parameters
const origUrl = url.parse(req.url || '/');
const origQuery = parseQueryString(origUrl.search);
Object.assign(origQuery, query);
origUrl.search = formatQueryString(origQuery);
req.url = url.format({
pathname: origUrl.pathname,
search: origUrl.search,
});
// Add the Vercel platform proxy request headers
const headers = this.getProxyHeaders(req, requestId, false);
for (const [name, value] of Object.entries(headers)) {
req.headers[name] = value;
}
this.setResponseHeaders(res, requestId);
return proxyPass(
req,
res,
`http://127.0.0.1:${port}`,
this,
requestId,
false
);
} else {
debug(`Skipping \`startDevServer()\` for ${match.entrypoint}`);
}
}
let foundAsset = findAsset(match, requestPath, vercelConfig);
if (!foundAsset && callLevel === 0) {
await this.triggerBuild(match, buildRequestPath, req, vercelConfig);
// Since the `asset` was just built, resolve again to get the new asset
foundAsset = findAsset(match, requestPath, vercelConfig);
}
// Proxy to the dev server:
// - when there is no asset
// - when the asset is not a Lambda (the dev server must take care of all static files)
if (
this.devProcessOrigin &&
(!foundAsset || (foundAsset && foundAsset.asset.type !== 'Lambda'))
) {
debug('Proxying to frontend dev server');
// Add the Vercel platform proxy request headers
const headers = this.getProxyHeaders(req, requestId, false);
for (const [name, value] of Object.entries(headers)) {
req.headers[name] = value;
}
this.setResponseHeaders(res, requestId);
return proxyPass(req, res, this.devProcessOrigin, this, requestId, false);
}
if (!foundAsset) {
await this.send404(req, res, requestId);
return;
}
const { asset, assetKey } = foundAsset;
debug(
`Serving asset: [${asset.type}] ${assetKey} ${
(asset as any).contentType || ''
}`
);
/* eslint-disable no-case-declarations */
switch (asset.type) {
case 'FileFsRef':
this.setResponseHeaders(res, requestId);
req.url = `/${basename(asset.fsPath)}`;
return serveStaticFile(req, res, dirname(asset.fsPath), {
headers: [
{
source: '**/*',
headers: [
{
key: 'Content-Type',
value: asset.contentType || getMimeType(assetKey),
},
],
},
],
});
case 'FileBlob':
const headers: http.OutgoingHttpHeaders = {
'Content-Length': asset.data.length,
'Content-Type': asset.contentType || getMimeType(assetKey),
};
this.setResponseHeaders(res, requestId, headers);
res.end(asset.data);
return;
case 'Lambda':
if (!asset.fn) {
// This is mostly to appease TypeScript since `fn` is an optional prop,
// but this shouldn't really ever happen since we run the builds before
// responding to HTTP requests.
await this.sendError(
req,
res,
requestId,
'INTERNAL_LAMBDA_NOT_FOUND'
);
return;
}
// When invoking lambda functions, the region where the lambda was invoked
// is also included in the request ID. So use the same `dev1` fake region.
requestId = generateRequestId(this.podId, true);
// Mix the `routes` result dest query params into the req path
const origUrl = url.parse(req.url || '/');
const origQuery = parseQueryString(origUrl.search);
Object.assign(origQuery, query);
origUrl.search = formatQueryString(origQuery);
const path = url.format({
pathname: origUrl.pathname,
search: origUrl.search,
});
const body = await rawBody(req);
const payload: InvokePayload = {
method: req.method || 'GET',
host: req.headers.host,
path,
headers: {
...req.headers,
...this.getProxyHeaders(req, requestId, true),
},
encoding: 'base64',
body: body.toString('base64'),
};
debug(`Invoking lambda: "${assetKey}" with ${path}`);
let result: InvokeResult;
try {
result = await asset.fn<InvokeResult>({
Action: 'Invoke',
body: JSON.stringify(payload),
});
} catch (err) {
console.error(err);
await this.sendError(
req,
res,
requestId,
'NO_RESPONSE_FROM_FUNCTION',
502
);
return;
}
if (!statusCode) {
res.statusCode = result.statusCode;
}
this.setResponseHeaders(res, requestId, result.headers);
let resBody: Buffer | string | undefined;
if (result.encoding === 'base64' && typeof result.body === 'string') {
resBody = Buffer.from(result.body, 'base64');
} else {
resBody = result.body;
}
return res.end(resBody);
default:
// This shouldn't really ever happen...
await this.sendError(req, res, requestId, 'UNKNOWN_ASSET_TYPE');
}
};
renderDirectoryListing(
_req: http.IncomingMessage,
res: http.ServerResponse,
requestPath: string,
requestId: string
): boolean {
// If the "directory listing" feature is disabled in the
// Project's settings, then don't render the directory listing
if (this.projectSettings?.directoryListing === false) {
return false;
}
let prefix = requestPath;
if (prefix.length > 0 && !prefix.endsWith('/')) {
prefix += '/';
}
const dirs: Set<string> = new Set();
const files = Array.from(this.buildMatches.keys())
.filter(p => {
const base = basename(p);
if (
base === 'now.json' ||
base === 'vercel.json' ||
base === '.nowignore' ||
base === '.vercelignore' ||
!p.startsWith(prefix)
) {
return false;
}
const rel = relative(prefix, p);
if (rel.includes('/')) {
const dir = rel.split('/')[0];
if (dirs.has(dir)) {
return false;
}
dirs.add(dir);
}
return true;
})
.map(p => {
let base = basename(p);
let ext = '';
let type = 'file';
let href: string;
const rel = relative(prefix, p);
if (rel.includes('/')) {
// Directory
type = 'folder';
base = rel.split('/')[0];
href = `/${prefix}${base}/`;
} else {
// File / Lambda
ext = extname(p).substring(1);
href = `/${prefix}${base}`;
}
return {
type,
relative: href,
ext,
title: href,
base,
};
});
if (files.length === 0) {
return false;
}
const directory = `/${prefix}`;
const paths = [
{
name: directory,
url: requestPath,
},
];
const directoryHtml = directoryTemplate({
files,
paths,
directory,
});
this.setResponseHeaders(res, requestId);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader(
'Content-Length',
String(Buffer.byteLength(directoryHtml, 'utf8'))
);
res.end(directoryHtml);
return true;
}
async hasFilesystem(
dest: string,
vercelConfig: VercelConfig
): Promise<boolean> {
if (
await findBuildMatch(
this.buildMatches,
this.files,
dest,
this,
vercelConfig,
true
)
) {
return true;
}
return false;
}
isCaseSensitive(): boolean {
return this.caseSensitive;
}
async runDevCommand() {
const { devCommand, cwd } = this;
if (devCommand === this.currentDevCommand) {
// `devCommand` has not changed, so don't restart frontend dev process
return;
}
this.currentDevCommand = devCommand;
if (!devCommand) {
return;
}
if (this.devProcess) {
await treeKill(this.devProcess.pid);
}
this.output.log(
`Running Dev Command ${chalk.cyan.bold(`${devCommand}`)}`
);
const port = await getPort();
const env: Env = cloneEnv(
{
// Because of child process 'pipe' below, isTTY will be false.
// Most frameworks use `chalk`/`supports-color` so we enable it anyway.
FORCE_COLOR: process.stdout.isTTY ? '1' : '0',
// Prevent framework dev servers from automatically opening a web
// browser window, since it will not be the port that `vc dev`
// is listening on and thus will be missing Vercel features.
BROWSER: 'none',
},
process.env,
this.envConfigs.allEnv,
{
PORT: `${port}`,
}
);
// add the node_modules/.bin directory to the PATH
const nodeBinPath = await getNodeBinPath({ cwd });
env.PATH = `${nodeBinPath}${path.delimiter}${env.PATH}`;
// This is necesary so that the dev command in the Project
// will work cross-platform (especially Windows).
let command = devCommand
.replace(/\$PORT/g, `${port}`)
.replace(/%PORT%/g, `${port}`);
this.output.debug(
`Starting dev command with parameters: ${JSON.stringify({
cwd,
command,
port,
})}`
);
this.output.debug(`Spawning dev command: ${command}`);
const proxyPort = new RegExp(port.toString(), 'g');
const p = spawnCommand(command, {
stdio: ['inherit', 'pipe', 'pipe'],
cwd,
env,
});
this.devProcess = p;
if (!p.stdout || !p.stderr) {
throw new Error('Expected child process to have stdout and stderr');
}
p.stderr.pipe(process.stderr);
p.stdout.setEncoding('utf8');
p.stdout.on('data', (data: string) => {
process.stdout.write(data.replace(proxyPort, this.address.port));
});
p.on('exit', (code, signal) => {
this.output.debug(`Dev command exited with "${signal || code}"`);
});
p.on('close', (code, signal) => {
this.output.debug(`Dev command closed with "${signal || code}"`);
this.devProcessOrigin = undefined;
});
const devProcessHost = await checkForPort(port, 1000 * 60 * 5);
this.devProcessOrigin = `http://${devProcessHost}:${port}`;
}
}
/**
* Mimic nginx's `proxy_pass` for routes using a URL as `dest`.
*/
function proxyPass(
req: http.IncomingMessage,
res: http.ServerResponse,
dest: string,
devServer: DevServer,
requestId: string,
ignorePath: boolean = true
): void {
return devServer.proxy.web(
req,
res,
{ target: dest, ignorePath },
(error: NodeJS.ErrnoException) => {
// only debug output this error because it's always something generic like
// "Error: socket hang up"
// and the original error should have already been logged
devServer.output.debug(
`Failed to complete request to ${req.url}: ${error}`
);
if (!res.headersSent) {
devServer.sendError(req, res, requestId, 'FUNCTION_INVOCATION_FAILED');
}
}
);
}
/**
* Handle requests for static files with serve-handler.
*/
function serveStaticFile(
req: http.IncomingMessage,
res: http.ServerResponse,
cwd: string,
opts?: object
) {
return serveHandler(req, res, {
public: cwd,
cleanUrls: false,
etag: true,
...opts,
});
}
function close(server: http.Server | httpProxy): Promise<void> {
return new Promise((resolve, reject) => {
server.close((err?: Error) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
/**
* Generates a (fake) tracing ID for an HTTP request.
*
* Example: dev1:q4wlg-1562364135397-7a873ac99c8e
*/
function generateRequestId(podId: string, isInvoke = false): string {
const invoke = isInvoke ? 'dev1::' : '';
return `dev1::${invoke}${[
podId,
Date.now(),
randomBytes(6).toString('hex'),
].join('-')}`;
}
function hasOwnProperty(obj: any, prop: string) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
async function findBuildMatch(
matches: Map<string, BuildMatch>,
files: BuilderInputs,
requestPath: string,
devServer: DevServer,
vercelConfig: VercelConfig,
isFilesystem = false
): Promise<BuildMatch | null> {
requestPath = requestPath.replace(/^\//, '');
let bestIndexMatch: undefined | BuildMatch;
for (const match of matches.values()) {
if (
await shouldServe(
match,
files,
requestPath,
devServer,
vercelConfig,
isFilesystem
)
) {
if (!isIndex(match.src)) {
return match;
} else {
// If isIndex === true and ends in `.html`, we're done.
// Otherwise, keep searching.
if (extname(match.src) === '.html') {
return match;
}
bestIndexMatch = match;
}
}
}
// return a non-.html index file or none are found
return bestIndexMatch || null;
}
async function shouldServe(
match: BuildMatch,
files: BuilderInputs,
requestPath: string,
devServer: DevServer,
vercelConfig: VercelConfig,
isFilesystem = false
): Promise<boolean> {
const {
src,
config,
builderWithPkg: { builder },
} = match;
// "middleware" file is not served as a regular asset,
// instead it gets invoked as part of the routing logic.
if (config?.middleware === true) {
return false;
}
const cleanSrc = src.endsWith('.html') ? src.slice(0, -5) : src;
const trimmedPath = requestPath.endsWith('/')
? requestPath.slice(0, -1)
: requestPath;
if (
vercelConfig.cleanUrls &&
vercelConfig.trailingSlash &&
cleanSrc === trimmedPath
) {
// Mimic fmeta-util and convert cleanUrls and trailingSlash
return true;
} else if (
vercelConfig.cleanUrls &&
!vercelConfig.trailingSlash &&
cleanSrc === requestPath
) {
// Mimic fmeta-util and convert cleanUrls
return true;
} else if (
!vercelConfig.cleanUrls &&
vercelConfig.trailingSlash &&
src === trimmedPath
) {
// Mimic fmeta-util and convert trailingSlash
return true;
} else if (typeof builder.shouldServe === 'function') {
const shouldServe = await builder.shouldServe({
entrypoint: src,
files,
config: config || {},
requestPath,
workPath: devServer.cwd,
});
if (shouldServe) {
return true;
}
} else if (findAsset(match, requestPath, vercelConfig)) {
// If there's no `shouldServe()` function, then look up if there's
// a matching build asset on the `match` that has already been built.
return true;
} else if (
!isFilesystem &&
(await findMatchingRoute(match, requestPath, devServer, vercelConfig))
) {
// If there's no `shouldServe()` function and no matched asset, then look
// up if there's a matching build route on the `match` that has already
// been built.
return true;
}
return false;
}
async function findMatchingRoute(
match: BuildMatch,
requestPath: string,
devServer: DevServer,
vercelConfig: VercelConfig
): Promise<RouteResult | void> {
const reqUrl = `/${requestPath}`;
for (const buildResult of match.buildResults.values()) {
if (!Array.isArray(buildResult.routes)) continue;
const route = await devRouter(
reqUrl,
undefined,
buildResult.routes,
devServer,
vercelConfig
);
if (route.found) {
return route;
}
}
}
function findAsset(
match: BuildMatch,
requestPath: string,
vercelConfig: VercelConfig
): { asset: BuilderOutput; assetKey: string } | void {
if (!match.buildOutput) {
return;
}
let assetKey: string = requestPath.replace(/\/$/, '');
let asset = match.buildOutput[requestPath];
if (vercelConfig.trailingSlash && requestPath.endsWith('/')) {
asset = match.buildOutput[requestPath.slice(0, -1)];
}
// In the case of an index path, fall back to iterating over the
// builder outputs and doing an "is index" check until a match is found.
if (!asset) {
for (const [name, a] of Object.entries(match.buildOutput)) {
if (isIndex(name) && dirnameWithoutDot(name) === assetKey) {
asset = a;
assetKey = name;
break;
}
}
}
if (asset) {
return { asset, assetKey };
}
}
function dirnameWithoutDot(path: string): string {
let dir = dirname(path);
if (dir === '.') {
dir = '';
}
return dir;
}
function isIndex(path: string): boolean {
const ext = extname(path);
const name = basename(path, ext);
return name === 'index';
}
function minimatches(files: string[], pattern: string): boolean {
return files.some(
file => file === pattern || minimatch(file, pattern, { dot: true })
);
}
function fileChanged(
name: string,
changed: Set<string>,
removed: Set<string>
): void {
changed.add(name);
removed.delete(name);
}
function fileRemoved(
name: string,
files: BuilderInputs,
changed: Set<string>,
removed: Set<string>
): void {
delete files[name];
changed.delete(name);
removed.add(name);
}
function needsBlockingBuild(buildMatch: BuildMatch): boolean {
const { builder } = buildMatch.builderWithPkg;
return typeof builder.shouldServe !== 'function';
}
async function checkForPort(port: number, timeout: number): Promise<string> {
let host;
const start = Date.now();
while (!(host = await getReachableHostOnPort(port))) {
if (Date.now() - start > timeout) {
break;
}
await sleep(100);
}
if (!host) {
throw new Error(`Detecting port ${port} timed out after ${timeout}ms`);
}
return host;
}
async function getReachableHostOnPort(port: number): Promise<string | false> {
const optsIpv4 = { host: '127.0.0.1' };
const optsIpv6 = { host: '::1' };
const results = await Promise.all([
isPortReachable(port, optsIpv6).then(r => r && `[${optsIpv6.host}]`),
isPortReachable(port, optsIpv4).then(r => r && optsIpv4.host),
]);
return results.find(Boolean) || false;
}
function filterFrontendBuilds(build: Builder) {
const { name } = npa(build.use);
return !frontendRuntimeSet.has(name || '');
}
function hasNewRoutingProperties(vercelConfig: VercelConfig) {
return (
typeof vercelConfig.cleanUrls !== undefined ||
typeof vercelConfig.headers !== undefined ||
typeof vercelConfig.redirects !== undefined ||
typeof vercelConfig.rewrites !== undefined ||
typeof vercelConfig.trailingSlash !== undefined
);
}
function buildMatchEquals(a?: BuildMatch, b?: BuildMatch): boolean {
if (!a || !b) return false;
if (a.src !== b.src) return false;
if (a.use !== b.use) return false;
if (!deepEqual(a.config || {}, b.config || {})) return false;
return true;
}