mirror of
https://github.com/zhigang1992/now-deployment.git
synced 2026-04-03 22:47:37 +08:00
1710 lines
47 KiB
TypeScript
1710 lines
47 KiB
TypeScript
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 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 } from './validate';
|
|
|
|
import isURL from './is-url';
|
|
import devRouter from './router';
|
|
import getMimeType from './mime-type';
|
|
import { getYarnPath } from './yarn-installer';
|
|
import { executeBuild, getBuildMatches } 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;
|
|
}
|
|
|
|
interface NodeRequire {
|
|
(id: string): any;
|
|
cache: {
|
|
[name: string]: any;
|
|
};
|
|
}
|
|
|
|
declare const __non_webpack_require__: NodeRequire;
|
|
|
|
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<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 getNowConfigPromise: Promise<NowConfig> | null;
|
|
private blockingBuildsPromise: Promise<void> | null;
|
|
private updateBuildersPromise: Promise<void> | 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.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<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);
|
|
}
|
|
}
|
|
|
|
// 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<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 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<string>,
|
|
removed: Set<string>
|
|
): 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<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) {
|
|
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<void> {
|
|
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
|
|
for (const src of this.buildMatches.keys()) {
|
|
if (!sources.includes(src)) {
|
|
this.output.debug(`Removing build match for "${src}"`);
|
|
// TODO: shutdown lambda functions
|
|
this.buildMatches.delete(src);
|
|
}
|
|
}
|
|
|
|
// 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 (!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<void> {
|
|
if (updatedBuilders.length === 0) {
|
|
this.output.debug('No builders were updated');
|
|
return;
|
|
}
|
|
|
|
const _require =
|
|
typeof __non_webpack_require__ === 'function'
|
|
? __non_webpack_require__
|
|
: require;
|
|
|
|
// The `require()` cache for the builder's assets must be purged
|
|
const builderDir = await builderDirPromise;
|
|
const updatedBuilderPaths = updatedBuilders.map(b =>
|
|
join(builderDir, 'node_modules', b)
|
|
);
|
|
for (const id of Object.keys(_require.cache)) {
|
|
for (const path of updatedBuilderPaths) {
|
|
if (id.startsWith(path)) {
|
|
this.output.debug(`Purging require cache for "${id}"`);
|
|
delete _require.cache[id];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
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<EnvConfig> {
|
|
// 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<NowConfig> {
|
|
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<NowConfig> {
|
|
if (canUseCache && this.cachedNowConfig) {
|
|
this.output.debug('Using cached `now.json` config');
|
|
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;
|
|
}
|
|
}
|
|
|
|
// no builds -> zero config
|
|
if (!config.builds || config.builds.length === 0) {
|
|
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`
|
|
);
|
|
|
|
const { builders, warnings, errors } = await detectBuilders(files, pkg, {
|
|
tag: getDistTag(cliVersion) === 'canary' ? 'canary' : 'latest',
|
|
});
|
|
|
|
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<PackageJson | null> {
|
|
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 validateNowConfig(config: NowConfig): Promise<void> {
|
|
if (config.version === 1) {
|
|
this.output.error('Only `version: 2` is supported by `now dev`');
|
|
await this.exit(1);
|
|
}
|
|
|
|
const buildsError = validateNowConfigBuilds(config);
|
|
|
|
if (buildsError) {
|
|
this.output.error(buildsError);
|
|
await this.exit(1);
|
|
}
|
|
|
|
const routesError = validateNowConfigRoutes(config);
|
|
|
|
if (routesError) {
|
|
this.output.error(routesError);
|
|
await this.exit(1);
|
|
}
|
|
}
|
|
|
|
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<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`);
|
|
}
|
|
|
|
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<string> = 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.invalidateBuildMatches(nowConfig, updatedBuilders)
|
|
)
|
|
.catch(err => {
|
|
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<void> {
|
|
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<void>[] = [];
|
|
|
|
for (const match of this.buildMatches.values()) {
|
|
if (!match.buildOutput) continue;
|
|
|
|
for (const asset of Object.values(match.buildOutput)) {
|
|
if (asset.type === 'Lambda' && asset.fn) {
|
|
ops.push(asset.fn.destroy());
|
|
}
|
|
}
|
|
}
|
|
|
|
ops.push(close(this.server));
|
|
|
|
if (this.watcher) {
|
|
this.watcher.close();
|
|
}
|
|
|
|
if (this.updateBuildersPromise) {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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.log(`${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 ([301, 302, 303].includes(status)) {
|
|
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);
|
|
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);
|
|
}
|
|
|
|
if (!foundAsset) {
|
|
await this.send404(req, res, nowRequestId);
|
|
return;
|
|
}
|
|
|
|
const { asset, assetKey } = foundAsset;
|
|
this.output.debug(`Serving asset: [${asset.type}] ${assetKey}`);
|
|
|
|
/* 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: getMimeType(assetKey),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
case 'FileBlob':
|
|
const headers: http.OutgoingHttpHeaders = {
|
|
'Content-Length': asset.data.length,
|
|
'Content-Type': 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<InvokeResult>({
|
|
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<string> = 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;
|
|
}
|
|
|
|
/**
|
|
* Serve project directory as a static deployment.
|
|
*/
|
|
serveProjectAsStatic = async (
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
nowRequestId: string
|
|
) => {
|
|
const filePath = req.url ? req.url.replace(/^\//, '') : '';
|
|
|
|
if (filePath && typeof this.files[filePath] === 'undefined') {
|
|
await this.send404(req, res, nowRequestId);
|
|
return;
|
|
}
|
|
|
|
this.setResponseHeaders(res, nowRequestId);
|
|
return serveStaticFile(req, res, this.cwd, { cleanUrls: true });
|
|
};
|
|
|
|
async hasFilesystem(dest: string): Promise<boolean> {
|
|
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<void> {
|
|
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<string, BuildMatch>,
|
|
files: BuilderInputs,
|
|
requestPath: string,
|
|
devServer: DevServer,
|
|
isFilesystem?: boolean
|
|
): Promise<BuildMatch | null> {
|
|
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<boolean> {
|
|
const {
|
|
src: entrypoint,
|
|
config,
|
|
builderWithPkg: { builder },
|
|
} = match;
|
|
if (typeof builder.shouldServe === 'function') {
|
|
const shouldServe = await builder.shouldServe({
|
|
entrypoint,
|
|
files,
|
|
config,
|
|
requestPath,
|
|
workPath: devServer.cwd,
|
|
});
|
|
if (shouldServe) {
|
|
return true;
|
|
}
|
|
} else if (findAsset(match, requestPath)) {
|
|
// 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<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
|
|
);
|
|
if (route.found) {
|
|
return route;
|
|
}
|
|
}
|
|
}
|
|
|
|
function findAsset(
|
|
match: BuildMatch,
|
|
requestPath: string
|
|
): { asset: BuilderOutput; assetKey: string } | void {
|
|
if (!match.buildOutput) {
|
|
return;
|
|
}
|
|
let assetKey: string = requestPath.replace(/\/$/, '');
|
|
let asset = match.buildOutput[requestPath];
|
|
|
|
// 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<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';
|
|
}
|