import ms from 'ms'; import url from 'url'; import http from 'http'; import fs from 'fs-extra'; import chalk from 'chalk'; 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 { basename, dirname, extname, join } from 'path'; import { getTransformedRoutes } from '@now/routing-utils'; import directoryTemplate from 'serve-handler/src/directory'; import { Builder, FileFsRef, PackageJson, detectBuilders, detectRoutes, } from '@now/build-utils'; import { once } from '../once'; import link from '../output/link'; import { Output } from '../output'; import { relative } from '../path-helpers'; import { getDistTag } from '../get-dist-tag'; import getNowConfigPath from '../config/local-path'; import { MissingDotenvVarsError } from '../errors-ts'; import { version as cliVersion } from '../../../package.json'; import { createIgnore, staticFiles as getFiles, getAllProjectFiles, } from '../get-files'; import { validateNowConfigBuilds, validateNowConfigRoutes, validateNowConfigCleanUrls, validateNowConfigHeaders, validateNowConfigRedirects, validateNowConfigRewrites, validateNowConfigTrailingSlash, validateNowConfigFunctions, } from './validate'; import isURL from './is-url'; import devRouter from './router'; import getMimeType from './mime-type'; import { getYarnPath } from './yarn-installer'; import { executeBuild, getBuildMatches, shutdownBuilder } from './builder'; import { generateErrorMessage, generateHttpStatusDescription } from './errors'; import { builderDirPromise, installBuilders, updateBuilders, } from './builder-cache'; // 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 { EnvConfig, NowConfig, DevServerOptions, BuildMatch, BuildResult, BuilderInputs, BuilderOutput, HttpHandler, InvokePayload, InvokeResult, ListenSpec, RouteConfig, RouteResult, } from './types'; interface FSEvent { type: string; path: string; } function sortBuilders(buildA: Builder, buildB: Builder) { if (buildA && buildA.use && buildA.use.startsWith('@now/static-build')) { return 1; } if (buildB && buildB.use && buildB.use.startsWith('@now/static-build')) { return -1; } return 0; } export default class DevServer { public cwd: string; public debug: boolean; public output: Output; public env: EnvConfig; public buildEnv: EnvConfig; public files: BuilderInputs; public yarnPath: string; public address: string; private cachedNowConfig: NowConfig | null; private server: http.Server; private stopping: boolean; private serverUrlPrinted: boolean; private buildMatches: Map; private inProgressBuilds: Map>; private watcher?: FSWatcher; private watchAggregationId: NodeJS.Timer | null; private watchAggregationEvents: FSEvent[]; private watchAggregationTimeout: number; private filter: (path: string) => boolean; private podId: string; private getNowConfigPromise: Promise | null; private blockingBuildsPromise: Promise | null; private updateBuildersPromise: Promise | null; constructor(cwd: string, options: DevServerOptions) { this.cwd = cwd; this.debug = options.debug; this.output = options.output; this.env = {}; this.buildEnv = {}; this.files = {}; this.address = ''; // This gets updated when `start()` is invoked this.yarnPath = '/'; this.cachedNowConfig = null; this.server = http.createServer(this.devServerHandler); this.server.timeout = 0; // Disable timeout this.serverUrlPrinted = false; this.stopping = false; this.buildMatches = new Map(); this.inProgressBuilds = new Map(); this.getNowConfigPromise = null; this.blockingBuildsPromise = null; this.updateBuildersPromise = null; this.watchAggregationId = null; this.watchAggregationEvents = []; this.watchAggregationTimeout = 500; this.filter = path => Boolean(path); this.podId = Math.random() .toString(32) .slice(-5); } 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 { this.output.debug(`Filesystem watcher notified of ${events.length} events`); const filesChanged: Set = new Set(); const filesRemoved: Set = 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); } } // Update the build matches in case an entrypoint was created or deleted const nowConfig = await this.getNowConfig(false); await this.updateBuildMatches(nowConfig); 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)) ) { this.triggerBuild( match, requestPath, null, 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, removed: Set ): Promise { const name = relative(this.cwd, fsPath); try { this.files[name] = await FileFsRef.fromFsPath({ fsPath }); fileChanged(name, changed, removed); this.output.debug(`File created: ${name}`); } catch (err) { if (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, removed: Set ): void { const name = relative(this.cwd, fsPath); this.output.debug(`File deleted: ${name}`); fileRemoved(name, this.files, changed, removed); } async handleFileModified( fsPath: string, changed: Set, removed: Set ): Promise { 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) { if (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( nowConfig: NowConfig, isInitial = false ): Promise { const fileList = this.resolveBuildFiles(this.files); const matches = await getBuildMatches( nowConfig, this.cwd, this.yarnPath, this.output, fileList ); const sources = matches.map(m => m.src); if (isInitial && fileList.length === 0) { this.output.warn( 'There are no files (or only files starting with a dot) inside your deployment.' ); } // Delete build matches that no longer exists const ops: Promise[] = []; 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[] = []; for (const match of matches) { const currentMatch = this.buildMatches.get(match.src); if (!currentMatch || currentMatch.use !== match.use) { this.output.debug(`Adding build match for "${match.src}"`); this.buildMatches.set(match.src, match); if (!isInitial && needsBlockingBuild(match)) { const buildPromise = executeBuild( nowConfig, this, this.files, match, null, false ); blockingBuilds.push(buildPromise); } } } if (blockingBuilds.length > 0) { const cleanup = () => { this.blockingBuildsPromise = null; }; this.blockingBuildsPromise = Promise.all(blockingBuilds) .then(cleanup) .catch(cleanup); } // Sort build matches to make sure `@now/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 invalidateBuildMatches( nowConfig: NowConfig, updatedBuilders: string[] ): Promise { if (updatedBuilders.length === 0) { this.output.debug('No builders were updated'); return; } // Delete any build matches that have the old builder required already for (const buildMatch of this.buildMatches.values()) { const { src, builderWithPkg: { package: pkg }, } = buildMatch; if (pkg.name === '@now/static') continue; if (pkg.name && updatedBuilders.includes(pkg.name)) { shutdownBuilder(buildMatch, this.output); this.buildMatches.delete(src); this.output.debug(`Invalidated build match for "${src}"`); } } // Re-add the build matches that were just removed, but with the new builder await this.updateBuildMatches(nowConfig); } async getLocalEnv(fileName: string, base?: EnvConfig): Promise { // 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: EnvConfig = {}; try { const dotenv = await fs.readFile(filePath, 'utf8'); this.output.debug(`Using local env: ${filePath}`); env = parseDotenv(dotenv); } catch (err) { if (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 {}; } async getNowConfig( canUseCache: boolean = true, isInitialLoad: boolean = false ): Promise { if (this.getNowConfigPromise) { return this.getNowConfigPromise; } this.getNowConfigPromise = this._getNowConfig(canUseCache, isInitialLoad); try { return await this.getNowConfigPromise; } finally { this.getNowConfigPromise = null; } } async _getNowConfig( canUseCache: boolean = true, isInitialLoad: boolean = false ): Promise { if (canUseCache && this.cachedNowConfig) { return this.cachedNowConfig; } const pkg = await this.getPackageJson(); // The default empty `now.json` is used to serve all files as static // when no `now.json` is present let config: NowConfig = this.cachedNowConfig || { version: 2 }; // We need to delete these properties for zero config to work // with file changes if (this.cachedNowConfig) { delete this.cachedNowConfig.builds; delete this.cachedNowConfig.routes; } try { this.output.debug('Reading `now.json` file'); const nowConfigPath = getNowConfigPath(this.cwd); config = JSON.parse(await fs.readFile(nowConfigPath, 'utf8')); } catch (err) { if (err.code === 'ENOENT') { this.output.debug('No `now.json` file present'); } else if (err.name === 'SyntaxError') { this.output.warn( `There is a syntax error in the \`now.json\` file: ${err.message}` ); } else { throw err; } } const allFiles = await getAllProjectFiles(this.cwd, this.output); const files = allFiles.filter(this.filter); this.output.debug( `Found ${allFiles.length} and ` + `filtered out ${allFiles.length - files.length} files` ); await this.validateNowConfig(config); const { error: routeError, routes: maybeRoutes } = getTransformedRoutes({ nowConfig: config, }); if (routeError) { this.output.error(routeError.message); await this.exit(); } config.routes = maybeRoutes || []; // no builds -> zero config if (!config.builds || config.builds.length === 0) { const { builders, warnings, errors } = await detectBuilders(files, pkg, { tag: getDistTag(cliVersion) === 'canary' ? 'canary' : 'latest', functions: config.functions, }); if (errors) { this.output.error(errors[0].message); await this.exit(); } if (warnings && warnings.length > 0) { warnings.forEach(warning => this.output.warn(warning.message)); } if (builders) { const { defaultRoutes, error: routesError } = await detectRoutes( files, builders ); config.builds = config.builds || []; config.builds.push(...builders); if (routesError) { this.output.error(routesError.message); await this.exit(); } else { config.routes = config.routes || []; config.routes.push(...(defaultRoutes as RouteConfig[])); } } } if (!config.builds || config.builds.length === 0) { if (isInitialLoad) { this.output.note(`Serving all files as static`); } } else if (Array.isArray(config.builds)) { // `@now/static-build` needs to be the last builder // since it might catch all other requests config.builds.sort(sortBuilders); } await this.validateNowConfig(config); this.cachedNowConfig = config; return config; } async getPackageJson(): Promise { const pkgPath = join(this.cwd, 'package.json'); let pkg: PackageJson | null = null; this.output.debug('Reading `package.json` file'); try { pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')); } catch (err) { if (err.code === 'ENOENT') { this.output.debug('No `package.json` file present'); } else if (err.name === 'SyntaxError') { this.output.warn( `There is a syntax error in the \`package.json\` file: ${err.message}` ); } else { throw err; } } return pkg; } async tryValidateOrExit( config: NowConfig, validate: (c: NowConfig) => string | null ): Promise { const message = validate(config); if (message) { this.output.error(message); await this.exit(1); } } async validateNowConfig(config: NowConfig): Promise { if (config.version === 1) { this.output.error('Only `version: 2` is supported by `now dev`'); await this.exit(1); } await this.tryValidateOrExit(config, validateNowConfigBuilds); await this.tryValidateOrExit(config, validateNowConfigRoutes); await this.tryValidateOrExit(config, validateNowConfigCleanUrls); await this.tryValidateOrExit(config, validateNowConfigHeaders); await this.tryValidateOrExit(config, validateNowConfigRedirects); await this.tryValidateOrExit(config, validateNowConfigRewrites); await this.tryValidateOrExit(config, validateNowConfigTrailingSlash); await this.tryValidateOrExit(config, validateNowConfigFunctions); } validateEnvConfig( type: string, env: EnvConfig = {}, localEnv: EnvConfig = {} ): EnvConfig { // Validate if there are any missing env vars defined in `now.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: EnvConfig = { ...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; } /** * Create an array of from builder inputs * and filter them */ resolveBuildFiles(files: BuilderInputs) { return Object.keys(files).filter(this.filter); } /** * Launches the `now dev` server. */ async start(...listenSpec: ListenSpec): Promise { 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`); } this.yarnPath = await getYarnPath(this.output); const ig = await createIgnore(join(this.cwd, '.nowignore')); this.filter = ig.createFilter(); // Retrieve the path of the native module const nowConfig = await this.getNowConfig(false, true); const nowConfigBuild = nowConfig.build || {}; const [env, buildEnv] = await Promise.all([ this.getLocalEnv('.env', nowConfig.env), this.getLocalEnv('.env.build', nowConfigBuild.env), ]); Object.assign(process.env, buildEnv); this.env = env; this.buildEnv = buildEnv; const opts = { output: this.output, isBuilds: true }; const files = await getFiles(this.cwd, nowConfig, opts); const results: { [filePath: string]: FileFsRef } = {}; for (const fsPath of files) { const path = relative(this.cwd, fsPath); const { mode } = await fs.stat(fsPath); results[path] = new FileFsRef({ mode, fsPath }); } this.files = results; const builders: Set = new Set( (nowConfig.builds || []) .filter((b: Builder) => b.use) .map((b: Builder) => b.use as string) ); await installBuilders(builders, this.yarnPath, this.output); await this.updateBuildMatches(nowConfig, true); // Updating builders happens lazily, and any builders that were updated // get their "build matches" invalidated so that the new version is used. this.updateBuildersPromise = updateBuilders( builders, this.yarnPath, this.output ) .then(updatedBuilders => { this.updateBuildersPromise = null; this.invalidateBuildMatches(nowConfig, updatedBuilders); }) .catch(err => { this.updateBuildersPromise = null; this.output.error(`Failed to update builders: ${err.message}`); this.output.debug(err.stack); }); // Now 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(nowConfig, this, this.files, match, null, true); } this.output.success('Build completed'); } // 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'); let address: string | null = null; while (typeof address !== 'string') { try { address = await listen(this.server, ...listenSpec); } catch (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 = address .replace('[::]', 'localhost') .replace('127.0.0.1', 'localhost'); this.output.ready(`Available at ${link(this.address)}`); this.serverUrlPrinted = true; } /** * Shuts down the `now dev` server, and cleans up any temporary resources. */ async stop(exitCode?: number): Promise { if (this.stopping) return; this.stopping = true; if (this.serverUrlPrinted) { // This makes it look cleaner process.stdout.write('\n'); this.output.log(`Stopping ${chalk.bold('`now dev`')} server`); } const ops: Promise[] = []; for (const match of this.buildMatches.values()) { ops.push(shutdownBuilder(match, this.output)); } ops.push(close(this.server)); if (this.watcher) { this.output.debug(`Closing file watcher`); this.watcher.close(); } if (this.updateBuildersPromise) { this.output.debug(`Waiting for builders update to complete`); ops.push(this.updateBuildersPromise); } try { await Promise.all(ops); } catch (err) { // Node 8 doesn't have a code for that error if ( err.code === 'ERR_SERVER_NOT_RUNNING' || err.message === 'Not running' ) { process.exit(exitCode || 0); } else { throw err; } } } shouldRebuild(req: http.IncomingMessage): boolean { return ( req.headers.pragma === 'no-cache' || req.headers['cache-control'] === 'no-cache' ); } async send404( req: http.IncomingMessage, res: http.ServerResponse, nowRequestId: string ): Promise { return this.sendError(req, res, nowRequestId, 'FILE_NOT_FOUND', 404); } async sendError( req: http.IncomingMessage, res: http.ServerResponse, nowRequestId: string, errorCode?: string, statusCode: number = 500 ): Promise { res.statusCode = statusCode; this.setResponseHeaders(res, nowRequestId); 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, now_id: nowRequestId, }); } else if (statusCode === 502) { view = errorTemplate502({ ...errorMessage, http_status_code: statusCode, http_status_description, error_code, now_id: nowRequestId, }); } else { view = errorTemplate({ http_status_code: statusCode, http_status_description, now_id: nowRequestId, }); } 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, nowRequestId: string, location: string, statusCode: number = 302 ): Promise { this.output.debug(`Redirect ${statusCode}: ${location}`); res.statusCode = statusCode; this.setResponseHeaders(res, nowRequestId, { 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'); body = redirectTemplate({ location, statusCode }); } else { res.setHeader('content-type', 'text/plain'); 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 Now headers to `res`. */ setResponseHeaders( res: http.ServerResponse, nowRequestId: string, headers: http.OutgoingHttpHeaders = {} ): void { const allHeaders = { 'cache-control': 'public, max-age=0, must-revalidate', ...headers, server: 'now', 'x-now-trace': 'dev1', 'x-now-id': nowRequestId, 'x-now-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. */ getNowProxyHeaders( req: http.IncomingMessage, nowRequestId: string ): http.IncomingHttpHeaders { const ip = this.getRequestIp(req); const { host } = req.headers; return { ...req.headers, Connection: 'close', 'x-forwarded-host': host, 'x-forwarded-proto': 'http', 'x-forwarded-for': ip, 'x-real-ip': ip, 'x-now-trace': 'dev1', 'x-now-deployment-url': host, 'x-now-id': nowRequestId, 'x-now-log-id': nowRequestId.split('-')[2], 'x-zeit-co-forwarded-for': ip, }; } async triggerBuild( match: BuildMatch, requestPath: string | null, req: http.IncomingMessage | null, previousBuildResult?: BuildResult, filesChanged?: string[], filesRemoved?: string[] ) { // If the requested asset wasn't found in the match's outputs, or // a hard-refresh was detected, then trigger a build const buildKey = requestPath === null ? match.src : `${match.src}-${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 (Date.now() - match.buildTimestamp < ms('2s')) { // If the built asset was created less than 2s ago, then don't trigger // a rebuild. The purpose of this threshold is because once an HTML page // is rebuilt, then the CSS/JS/etc. assets on the page are also refreshed // with a `no-cache` header, so this avoids *two* rebuilds for that case. let msg = `Skipping build for "${buildKey}" (not older than 2s)`; if (req) msg += ` for "${req.method} ${req.url}"`; this.output.debug(msg); } else { const nowConfig = await this.getNowConfig(); 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( nowConfig, 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); } } /** * DevServer HTTP handler */ devServerHandler: HttpHandler = async ( req: http.IncomingMessage, res: http.ServerResponse ) => { const nowRequestId = generateRequestId(this.podId); if (this.stopping) { res.setHeader('Connection', 'close'); await this.send404(req, res, nowRequestId); return; } const method = req.method || 'GET'; this.output.debug(`${chalk.bold(method)} ${req.url}`); try { const nowConfig = await this.getNowConfig(); await this.serveProjectAsNowV2(req, res, nowRequestId, nowConfig); } catch (err) { console.error(err); this.output.debug(err.stack); if (!res.finished) { res.statusCode = 500; res.end(err.message); } } }; /** * Serve project directory as a Now v2 deployment. */ serveProjectAsNowV2 = async ( req: http.IncomingMessage, res: http.ServerResponse, nowRequestId: string, nowConfig: NowConfig, routes: RouteConfig[] | undefined = nowConfig.routes, callLevel: number = 0 ) => { // 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, nowRequestId, location, 301); return; } this.output.debug(`Rewriting URL from "${req.url}" to "${location}"`); req.url = location; } await this.updateBuildMatches(nowConfig); if (this.blockingBuildsPromise) { this.output.debug( 'Waiting for builds to complete before handling request' ); await this.blockingBuildsPromise; } const { dest, status, headers, uri_args } = await devRouter( req.url, req.method, routes, this ); // Set any headers defined in the matched `route` config Object.entries(headers).forEach(([name, value]) => { res.setHeader(name, value); }); if (isURL(dest)) { // Mix the `routes` result dest query params into the req path const destParsed = url.parse(dest, true); delete destParsed.search; Object.assign(destParsed.query, uri_args); const destUrl = url.format(destParsed); this.output.debug(`ProxyPass: ${destUrl}`); this.setResponseHeaders(res, nowRequestId); return proxyPass(req, res, destUrl, this.output); } if (status) { res.statusCode = status; if (300 <= status && status <= 399) { await this.sendRedirect( req, res, nowRequestId, res.getHeader('location') as string, status ); return; } } const requestPath = dest.replace(/^\//, ''); const match = await findBuildMatch( this.buildMatches, this.files, requestPath, this ); if (!match) { if ( status === 404 || !this.renderDirectoryListing(req, res, requestPath, nowRequestId) ) { await this.send404(req, res, nowRequestId); } 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 || '/', true); delete origUrl.search; origUrl.pathname = dest; Object.assign(origUrl.query, uri_args); const newUrl = url.format(origUrl); this.output.debug( `Checking build result's ${buildResult.routes.length} \`routes\` to match ${newUrl}` ); const matchedRoute = await devRouter( newUrl, req.method, buildResult.routes, this ); if (matchedRoute.found && callLevel === 0) { this.output.debug( `Found matching route ${matchedRoute.dest} for ${newUrl}` ); req.url = newUrl; await this.serveProjectAsNowV2( req, res, nowRequestId, nowConfig, buildResult.routes, callLevel + 1 ); return; } } let foundAsset = findAsset(match, requestPath, nowConfig); if ((!foundAsset || this.shouldRebuild(req)) && callLevel === 0) { await this.triggerBuild(match, buildRequestPath, req); // Since the `asset` was re-built, resolve it again to get the new asset foundAsset = findAsset(match, requestPath, nowConfig); } if (!foundAsset) { await this.send404(req, res, nowRequestId); return; } const { asset, assetKey } = foundAsset; this.output.debug( `Serving asset: [${asset.type}] ${assetKey} ${(asset as any) .contentType || ''}` ); /* eslint-disable no-case-declarations */ switch (asset.type) { case 'FileFsRef': this.setResponseHeaders(res, nowRequestId); 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, nowRequestId, 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, nowRequestId, 'INTERNAL_LAMBDA_NOT_FOUND' ); return; } // Mix the `routes` result dest query params into the req path const parsed = url.parse(req.url || '/', true); Object.assign(parsed.query, uri_args); const path = url.format({ pathname: parsed.pathname, query: parsed.query, }); const body = await rawBody(req); const payload: InvokePayload = { method: req.method || 'GET', host: req.headers.host, path, headers: this.getNowProxyHeaders(req, nowRequestId), encoding: 'base64', body: body.toString('base64'), }; this.output.debug(`Invoking lambda: "${assetKey}" with ${path}`); let result: InvokeResult; try { result = await asset.fn({ Action: 'Invoke', body: JSON.stringify(payload), }); } catch (err) { console.error(err); await this.sendError( req, res, nowRequestId, 'NO_STATUS_CODE_FROM_LAMBDA', 502 ); return; } if (!status) { res.statusCode = result.statusCode; } this.setResponseHeaders(res, nowRequestId, 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, nowRequestId, 'UNKNOWN_ASSET_TYPE'); } }; renderDirectoryListing( req: http.IncomingMessage, res: http.ServerResponse, requestPath: string, nowRequestId: string ): boolean { let prefix = requestPath; if (prefix.length > 0 && !prefix.endsWith('/')) { prefix += '/'; } const dirs: Set = new Set(); const files = Array.from(this.buildMatches.keys()) .filter(p => { const base = basename(p); if ( base === 'now.json' || base === '.nowignore' || !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, nowRequestId); 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): Promise { const requestPath = dest.replace(/^\//, ''); if ( await findBuildMatch( this.buildMatches, this.files, requestPath, this, true ) ) { return true; } return false; } } /** * Mimic nginx's `proxy_pass` for routes using a URL as `dest`. */ function proxyPass( req: http.IncomingMessage, res: http.ServerResponse, dest: string, output: Output ): void { const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true, xfwd: true, ignorePath: true, target: dest, }); proxy.on('error', (error: NodeJS.ErrnoException) => { // If the client hangs up a socket, we do not // want to do anything, as the client just expects // the connection to be closed. if (error.code === 'ECONNRESET') { res.end(); return; } output.error(`Failed to complete request to ${req.url}: ${error}`); }); return proxy.web(req, res); } /** * 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): Promise { return new Promise((resolve, reject) => { server.close((err?: Error) => { if (err) { reject(err); } else { resolve(); } }); }); } /** * Generates a (fake) Now tracing ID for an HTTP request. * * Example: dev1:q4wlg-1562364135397-7a873ac99c8e */ function generateRequestId(podId: string): string { return `dev1:${[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, files: BuilderInputs, requestPath: string, devServer: DevServer, isFilesystem?: boolean ): Promise { for (const match of matches.values()) { if (await shouldServe(match, files, requestPath, devServer, isFilesystem)) { return match; } } return null; } async function shouldServe( match: BuildMatch, files: BuilderInputs, requestPath: string, devServer: DevServer, isFilesystem?: boolean ): Promise { const { src, config, builderWithPkg: { builder }, } = match; const nowConfig = await devServer.getNowConfig(); const cleanSrc = src.endsWith('.html') ? src.slice(0, -5) : src; const trimmedPath = requestPath.endsWith('/') ? requestPath.slice(0, -1) : requestPath; if ( nowConfig.cleanUrls && nowConfig.trailingSlash && cleanSrc === trimmedPath ) { // Mimic fmeta-util and convert cleanUrls and trailingSlash return true; } else if ( nowConfig.cleanUrls && !nowConfig.trailingSlash && cleanSrc === requestPath ) { // Mimic fmeta-util and convert cleanUrls return true; } else if ( !nowConfig.cleanUrls && nowConfig.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, requestPath, workPath: devServer.cwd, }); if (shouldServe) { return true; } } else if (findAsset(match, requestPath, nowConfig)) { // 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)) ) { // 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 ): Promise { 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 ); if (route.found) { return route; } } } function findAsset( match: BuildMatch, requestPath: string, nowConfig: NowConfig ): { asset: BuilderOutput; assetKey: string } | void { if (!match.buildOutput) { return; } let assetKey: string = requestPath.replace(/\/$/, ''); let asset = match.buildOutput[requestPath]; if (nowConfig.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)); } function fileChanged( name: string, changed: Set, removed: Set ): void { changed.add(name); removed.delete(name); } function fileRemoved( name: string, files: BuilderInputs, changed: Set, removed: Set ): void { delete files[name]; changed.delete(name); removed.add(name); } function needsBlockingBuild(buildMatch: BuildMatch): boolean { const { builder } = buildMatch.builderWithPkg; return typeof builder.shouldServe !== 'function'; }