@magento/pwa-buildpack
Version:
Build/Layout optimization tooling and Peregrine framework adapters for the Magento PWA
252 lines (245 loc) • 10.5 kB
JavaScript
const path = require('path');
const fs = require('fs');
const buildpackName = require('../../package.json').name;
/**
* @typedef {function(TransformRequest)} addTransform
* Add a request to transform a file in the build. This function is passed as
* the first argument to an interceptor of the `transformModules` target.
*
* @param {TransformRequest} req - Instruction object for the requested
* transform, including the transform to apply, the target source code, and
* other options.
*
* @returns null
*/
/** @enum {string} */
const TransformType = {
/**
* Process the _source code_ of `fileToTransform` through the
* `transformModule` as text. When applying a `source` TransformRequest,
* Buildpack will use the `transformModule` as a [Webpack
* loader](https://v4.webpack.js.org/api/loaders/), so it must implement
* that interface. Any Webpack loader can be used as a `transformModule`
* for `source` TransformRequests.
*
* `source` transforms are fast and can run on source code of any language,
* but they aren't as precise and safe as AST-type transforms when modifying
* code.
*/
source: 'source',
/**
* Process the _abstract syntax tree_ of the ES module specified by
* `fileToTransform` through the `transformModule` as a [Babel
* AST](https://github.com/babel/babel/blob/master/packages/babel-parser/ast/spec.md).
* When applying a `babel` TransformRequest, Buildpack will use the
* `transformModule` as a [Babel
* plugin](https://github.com/jamiebuilds/babel-handbook), so it must
* implement that interface. Any Babel plugin can be used as a
* `transformModule` for `babel` TransformRequests.
*
* `babel` transforms are powerful and versatile, giving the transformer
* much more insight into the structure of the source code to modify.
* However, they are slower than `source` transforms, and they can only
* work on ES Modules.
*/
babel: 'babel'
};
/**
* @typedef {Object} TransformRequest
* Instruction for configuring Webpack to apply custom transformations to one
* particular file. The [`configureWebpack()` function]{@link /pwa-buildpack/reference/configure-webpack/}
* gathers TransformRequests from all interceptors of the `transformModules`
* target and turns them into a configuration of Webpack [module
* rules](https://v4.webpack.js.org/configuration/module/#modulerules).
*
* @prop {TransformType} type - The type of transformation to apply.
* @prop {string} fileToTransform - Resolvable path to the file to be transformed itself, the same path that you'd use in `import` or `require()`.
* @prop {string} transformModule - Absolute path to the Node module that will actually be doing the transforming. This path may be resolved using different
* rules at different times, so it's best for this path to always be absolute.
* @prop {object} [options] - Config values to send to the transform function.
* _Note: Options should be serializable to JSON as Webpack loader options
* and/or Babel plugin options.._
*/
/**
* Configuration builder for module transforms. Accepts TransformRequests
* and emits loader config objects for Buildpack's custom transform loaders.
*
* Understands all transform types and normalizes them correctly. Mostly this
* involves resolving the file paths using Webpack or Node resolution rules.
*
* For some special types of transform, ModuleTransformConfig has helpers to
* apply the requested transforms itself. But `configureWebpack` consumes most
* of the transforms by calling `transformConfig.collect()` on this object,
* which yields a structured object that configureWebpack can use to set up
* loader and plugin configuration.
*/
class ModuleTransformConfig {
/**
*
* @static
* @constructs
* @param {MagentoResolver} resolver - Resolver to use when finding real paths of
* modules requested.
* @param {string} localProjectName - The name of the PWA project being built, taken from the package.json `name` field.
*/
constructor(resolver, localProjectName) {
this._resolver = resolver;
this._localProjectName = localProjectName;
// TODO: Currently nothing changes the resolver, but it will definitely
// be necessary to deal with this in the future. Trust me, you want to
// make sure successive transforms obey the rules that their predecessor
// transforms have set up.
this._resolverChanges = [];
this._needsResolved = [];
}
/**
* @borrows addTransform as add
*/
add(request) {
if (!TransformType.hasOwnProperty(request.type)) {
throw this._traceableError(
`Unknown request type '${
request.type
}' in TransformRequest: ${JSON.stringify(request)}`
);
}
this._needsResolved.push(this._resolveOrdinary(request));
}
/**
* Resolve paths and emit as JSON.
*
* @returns {object} Configuration object
*/
async toLoaderOptions() {
const byType = Object.values(TransformType).reduce(
(grouped, type) => ({
...grouped,
[type]: {}
}),
{}
);
// Resolver still may need updating! Updates should be in order.
for (const resolverUpdate of this._resolverChanges) {
await resolverUpdate();
}
// Now the requests can be made using the finished resolver!
await Promise.all(
this._needsResolved.map(async doResolve => {
const req = await doResolve();
// Split them up by the transform module to use.
// Several requests will share one transform instance.
const { type, transformModule, fileToTransform } = req;
const xformsForType = byType[type];
const filesForXform =
xformsForType[transformModule] ||
(xformsForType[transformModule] = {});
const requestsForFile =
filesForXform[fileToTransform] ||
(filesForXform[fileToTransform] = []);
requestsForFile.push(req);
})
);
return JSON.parse(JSON.stringify(byType));
}
/**
* Prevent modules from transforming files from other modules.
* Preserves encapsulation and maintainability.
* @private
*/
_assertAllowedToTransform({ requestor, fileToTransform }) {
if (
!this._isLocal(requestor) && // Local project can modify anything
!this._isBuiltin(requestor) && // Buildpack itself can modify anything
!this._isTrustedExtensionVendor(requestor) && // Trusted extension vendors can modify anything
!fileToTransform.startsWith(requestor)
) {
throw this._traceableError(
`Invalid fileToTransform path "${fileToTransform}": Extensions are not allowed to provide fileToTransform paths outside their own codebase! This transform request from "${requestor}" must provide a path to one of its own modules, starting with "${requestor}".`
);
}
}
_isBuiltin(requestor) {
return requestor === buildpackName;
}
_isLocal(requestor) {
return requestor === this._localProjectName;
}
_isTrustedExtensionVendor(requestor) {
const vendors = this._getTrustedExtensionVendors();
const requestorVendor = requestor.split('/')[0];
return requestorVendor.length > 0 && vendors.includes(requestorVendor);
}
_getTrustedExtensionVendors() {
const configPath = path.resolve(process.cwd(), 'package.json');
if (!fs.existsSync(configPath)) {
return [];
}
const config = require(configPath)['pwa-studio'];
const configSectionName = 'trusted-vendors';
return config && config[configSectionName]
? config[configSectionName]
: [];
}
_traceableError(msg) {
const capturedError = new Error(`ModuleTransformConfig: ${msg}`);
Error.captureStackTrace(capturedError, ModuleTransformConfig);
return new Error(capturedError.stack);
}
// Must throw a synchronous error so that .add() can throw early on a
// disallowed module. So this is not an async function--instead it deals in
// promise-returning function directly.
_resolveOrdinary(request) {
this._assertAllowedToTransform(request);
const transformModule = this._resolveNode(request, 'transformModule');
return () =>
this._resolveWebpack(request, 'fileToTransform').then(
fileToTransform => ({
...request,
fileToTransform,
transformModule
})
);
}
async _resolveWebpack(request, prop) {
const requestPath = request[prop];
// make module-absolute if relative
const toResolve = requestPath.startsWith('.')
? path.join(request.requestor, requestPath)
: requestPath;
// Capturing in the sync phase so that a resolve failure is traceable.
const resolveError = this._traceableError(
`could not resolve ${prop} "${toResolve}" from requestor ${
request.requestor
} using Webpack rules.`
);
try {
const resolved = await this._resolver.resolve(toResolve);
return resolved;
} catch (e) {
resolveError.originalErrors = [e];
throw resolveError;
}
}
_resolveNode(request, prop) {
let nodeModule;
try {
nodeModule = require.resolve(request[prop]);
} catch (e) {
try {
nodeModule = require.resolve(
path.join(request.requestor, request[prop])
);
} catch (innerE) {
const resolveError = this._traceableError(
`could not resolve ${prop} ${
request[prop]
} from requestor ${request.requestor} using Node rules.`
);
resolveError.originalErrors = [e, innerE];
throw resolveError;
}
}
return nodeModule;
}
}
module.exports = ModuleTransformConfig;