@apployees-nx/webserver
Version:
A create-react-app inspired plugin for Nx, with SSR and PWA capabilities.
411 lines (371 loc) • 16.5 kB
text/typescript
/*******************************************************************************
* © Apployees Inc., 2019
* All Rights Reserved.
******************************************************************************/
import path, { resolve } from "path";
import fs from "fs-extra";
import chalk from "chalk";
import { BuilderContext, createBuilder } from "@angular-devkit/architect";
import { JsonObject } from "@angular-devkit/core";
import { DevServerBuildOutput, runWebpack, runWebpackDevServer } from "@angular-devkit/build-webpack";
import { forkJoin, from, Observable, of } from "rxjs";
import { concatMap, map, switchMap } from "rxjs/operators";
import {
getSourceRoot,
loadEnvironmentVariables,
OUT_FILENAME,
WebpackBuildEvent,
writePackageJson,
} from "@apployees-nx/common-build-utils";
import { IBuildWebserverBuilderOptions } from "../../utils/common/webserver-types";
import { normalizeBuildOptions } from "../../utils/common/normalize";
import { getServerConfig } from "../../utils/server/server-config";
import { getClientConfig } from "../../utils/client/client-config";
import { checkBrowsers } from "react-dev-utils/browsersHelper";
import FileSizeReporter from "react-dev-utils/FileSizeReporter";
import { choosePort, createCompiler, prepareUrls, printInstructions } from "../../utils/client/WebpackDevServerUtils";
import errorOverlayMiddleware from "react-dev-utils/errorOverlayMiddleware";
import evalSourceMapMiddleware from "react-dev-utils/evalSourceMapMiddleware";
import _ from "lodash";
import webpack, { Configuration } from "webpack";
import escape from "escape-string-regexp";
import WebpackDevServer from "webpack-dev-server";
import noopServiceWorkerMiddleware from "react-dev-utils/noopServiceWorkerMiddleware";
(process as NodeJS.EventEmitter).on("uncaughtException", async (thrown: any) => {
console.error("Uncaught error:", thrown);
process.exit(1);
});
const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;
try {
require("dotenv").config();
} catch (e) {
console.error("Error while loading dotenv config.");
console.error(e);
}
export default createBuilder<JsonObject & IBuildWebserverBuilderOptions>(run);
interface IWebpackDevServerReference {
server: WebpackDevServer & { sockWrite: Function; sockets: any };
}
function run(
options: JsonObject & IBuildWebserverBuilderOptions,
context: BuilderContext,
): Observable<WebpackBuildEvent> {
const nodeEnv: string = options.dev ? "development" : "production";
// do this otherwise our bootstrapped @apployees-nx/node actually replaces this
// to "development" or "production" at build time.
const nodeEnvKey = "NODE_ENV";
const babelEnvKey = "BABEL_ENV";
process.env[nodeEnvKey] = nodeEnv;
process.env[babelEnvKey] = nodeEnv;
const devServer: IWebpackDevServerReference = { server: null };
const devSocket = {
warnings: (warnings) => {
devServer.server.sockWrite(devServer.server.sockets, "warnings", warnings);
},
errors: (errors) => {
devServer.server.sockWrite(devServer.server.sockets, "errors", errors);
},
};
let yarnExists;
let devClientFirstTimeComplete = false,
devServerFirstTimeComplete = false;
return from(getSourceRoot(context)).pipe(
map((sourceRoot) => normalizeBuildOptions(options, context, sourceRoot)),
switchMap((options: IBuildWebserverBuilderOptions) =>
checkBrowsers(path.resolve(options.root, options.sourceRoot), isInteractive).then(() => options),
),
switchMap((options: IBuildWebserverBuilderOptions) => {
yarnExists = fs.existsSync(path.resolve(options.root, "yarn.lock"));
loadEnvironmentVariables(options, context);
if (options.dev) {
return choosePort(options.devHost, options.devAppPort).then((appPort) => {
if (_.isNil(appPort)) {
throw new Error("Could not start because we could not find a port for app server.");
}
options.devAppPort = appPort;
process.env.PORT = appPort;
return choosePort(options.devHost, options.devWebpackPort).then((webpackPort) => {
if (_.isNil(webpackPort)) {
throw new Error("Could not start because we could not find a port for the webpack server.");
}
options.devWebpackPort = webpackPort;
process.env.DEV_PORT = webpackPort;
const protocol = options.devHttps ? "https" : "http";
options.assetsUrl = `${protocol}://${options.devHost}:${webpackPort}/`;
// eslint-disable-next-line @typescript-eslint/camelcase
options.devUrls_calculated = prepareUrls(protocol, options.devHost, appPort);
return options;
});
});
} else {
return Promise.resolve(options);
}
}),
switchMap((options: IBuildWebserverBuilderOptions) => {
if (!options.dev) {
return measureFileSizesBeforeBuild(
options.publicOutputFolder_calculated,
).then((previousFileSizesForPublicFolder) => [options, previousFileSizesForPublicFolder]);
} else {
return Promise.resolve([options, null]);
}
}),
map(([options, previousFileSizesForPublicFolder]) => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(options.outputPath);
return [options, previousFileSizesForPublicFolder];
}),
map(([options, previousFileSizesForPublicFolder]) => {
if (!fs.existsSync(options.appHtml) || !fs.existsSync(options.clientMain) || !fs.existsSync(options.serverMain)) {
throw new Error("One of appHtml, clientMain, or serverMain is not specified.");
}
let serverConfig = getServerConfig(options, context, true);
if (options.serverWebpackConfig) {
serverConfig = __non_webpack_require__(options.serverWebpackConfig)(serverConfig, {
options,
configuration: context.target.configuration,
});
}
let clientConfig = getClientConfig(options, context, false);
if (options.clientWebpackConfig) {
clientConfig = __non_webpack_require__(options.clientWebpackConfig)(clientConfig, {
options,
configuration: context.target.configuration,
});
}
// remove the output directory before we go further
return [options, serverConfig, clientConfig, previousFileSizesForPublicFolder];
}),
concatMap(
([options, serverConfig, clientConfig, previousFileSizesForPublicFolder]: [
IBuildWebserverBuilderOptions,
Configuration,
Configuration,
object,
]) => {
if (options.dev) {
/**
* Run the webpack for server and webpack dev server for client.
*/
return forkJoin(
runWebpack(serverConfig, context, {
logging: (stats) => {
context.logger.info(stats.toString(serverConfig.stats));
devServerFirstTimeComplete = true;
if (devClientFirstTimeComplete && devServerFirstTimeComplete) {
printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
}
},
webpackFactory: (config: Configuration) =>
of(
createCompiler({
webpack: webpack,
config: serverConfig,
appName: context.target.project + " - Server",
useYarn: yarnExists,
tscCompileOnError: true,
useTypeScript: true,
devSocket: devSocket,
urls: options.devUrls_calculated,
}),
),
}),
runWebpackDevServer(clientConfig, context, {
logging: (stats) => {
context.logger.info(stats.toString(clientConfig.stats));
devClientFirstTimeComplete = true;
if (devClientFirstTimeComplete && devServerFirstTimeComplete) {
printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
}
},
devServerConfig: createWebpackServerOptions(options, context, devServer),
webpackFactory: (config: webpack.Configuration) =>
of(
createCompiler({
webpack: webpack,
config: clientConfig,
appName: context.target.project + " - Client",
useYarn: yarnExists,
tscCompileOnError: true,
useTypeScript: true,
devSocket: devSocket,
urls: options.devUrls_calculated,
}) as webpack.Compiler,
),
}).pipe(
map((output) => {
output.baseUrl = options.devUrls_calculated.localUrlForBrowser;
return output;
}),
),
of(options),
);
} else {
/**
* Run the webpack for server and webpack for client.
*/
return forkJoin(
runWebpack(serverConfig, context, {
logging: (stats) => {
context.logger.info(stats.toString(serverConfig.stats));
},
}),
runWebpack(clientConfig, context, {
logging: (stats) => {
context.logger.info(stats.toString(clientConfig.stats));
console.log(previousFileSizesForPublicFolder);
context.logger.info("\n\nFile sizes of files in /public after gzip:\n");
printFileSizesAfterBuild(
stats,
previousFileSizesForPublicFolder,
options.publicOutputFolder_calculated,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE,
);
},
}),
of(options),
);
}
},
),
map(
([serverBuildEvent, clientBuildEventOrDevServerBuildOutput, options]: [
WebpackBuildEvent,
WebpackBuildEvent | DevServerBuildOutput,
IBuildWebserverBuilderOptions,
]) => {
if (!options.dev) {
serverBuildEvent.success = serverBuildEvent.success && clientBuildEventOrDevServerBuildOutput.success;
serverBuildEvent.error = serverBuildEvent.error && clientBuildEventOrDevServerBuildOutput.error;
serverBuildEvent.outfile = resolve(context.workspaceRoot, options.outputPath, OUT_FILENAME);
return [serverBuildEvent as WebpackBuildEvent, options];
} else {
return [clientBuildEventOrDevServerBuildOutput as DevServerBuildOutput, options];
}
},
),
map(
([clientBuildEventOrDevServerBuildOutput, options]: [
WebpackBuildEvent & DevServerBuildOutput,
IBuildWebserverBuilderOptions,
]) => {
// we only consider server external dependencies and libraries because it is the server
// code that is run by node, not the browser code.
if (!options.dev) {
writePackageJson(options, context, options.serverExternalDependencies, options.serverExternalLibraries);
printHostingInstructions(options);
return clientBuildEventOrDevServerBuildOutput;
} else {
printInstructions(context.target.project, options.devUrls_calculated, yarnExists);
return clientBuildEventOrDevServerBuildOutput;
}
},
),
);
}
function printHostingInstructions(options: IBuildWebserverBuilderOptions) {
const assetsPath = options.assetsUrl;
// eslint-disable-next-line @typescript-eslint/camelcase
const publicOutputFolder_calculated = options.publicOutputFolder_calculated;
const buildFolder = options.outputPath;
console.log(
`\n\n\n\nThe project was built assuming all static assets are served from the path '${chalk.green(assetsPath)}'.`,
);
console.log();
if (assetsPath.startsWith("/")) {
console.log(
`All of your static assets will be served from the rendering server (specifically from ${chalk.green(
publicOutputFolder_calculated,
)}).`,
);
console.log("\nWe recommend serving static assets from a CDN in production.");
console.log(
`\nYou can control this with the ${chalk.cyan(
"ASSETS_URL",
)} environment variable and set its value to the CDN URL for your next build.`,
);
console.log();
}
console.log(`The ${chalk.cyan(buildFolder)} folder is ready to be deployed.`);
console.log();
console.log("You may run the app with node:");
console.log();
console.log(` ${chalk.cyan("node")} ${buildFolder}`);
console.log();
}
function createWebpackServerOptions(
options: IBuildWebserverBuilderOptions,
context: BuilderContext,
serverReference: IWebpackDevServerReference,
) {
const config: WebpackDevServer.Configuration & { logLevel?: string } = {
// this needs to remain disabled because our webpackdevserver runs on a
// different port than the server app.
disableHostCheck: true,
// Enable gzip compression of generated files.
compress: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: process.env.ASSETS_URL || options.assetsUrl,
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
logLevel: "warn",
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(path.resolve(options.root, options.sourceRoot)),
},
host: options.devHost,
port: options.devWebpackPort,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization",
},
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: options.devUrls_calculated.lanUrlForConfig,
https: options.devHttps,
before(app, server) {
serverReference.server = server as any;
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware());
},
};
return config;
}
function ignoredFiles(appSrc) {
return new RegExp(`^(?!${escape(path.normalize(appSrc + "/").replace(/[\\]+/g, "/"))}).+/node_modules/`, "g");
}
// eslint-disable-next-line @typescript-eslint/camelcase
declare function __non_webpack_require__(string): any;