Skip to content

Commit

Permalink
Support a meteor.mainModule section in application package.json files.
Browse files Browse the repository at this point in the history
meteor/meteor-feature-requests#135

This change allows applications to specify specific entry points for each
architecture, without relying on `imports` directories to determine the
eagerness/laziness of modules. In other words, it will finally be possible
to build a Meteor app without a special `imports` directory.

Specifically, if `packageJson.meteor.mainModule[architecture]` is defined,
all modules for that architecture will be lazy except for the specified
module, which will be loaded eagerly.

Possible values for `architecture` include "client", "server", "web",
"web.browser", "web.cordova", "os", and so on, just like the second
argument to `api.mainModule(file, where)` in Meteor packages.

In order to match existing behavior, a Meteor application might include
the following in its `package.json` file:

  "meteor": {
    "mainModule": {
      "client": "client/main.js",
      "server": "server/main.js"
    }
  }

These architectures are handled independently, so omitting the "client" or
"server" property would cause that architecture to revert to standard
Meteor loading semantics. In other words, Meteor developers must opt into
this functionality, which is crucial for backwards compatibility.

Note that this functionality applies only to application modules, since
modules in Meteor packages are already lazy by default, and Meteor
packages can already specify entry points by calling `api.mainModule` in
their `package.js` files.

Also note that the loading behavior of non-JavaScript resources is *not*
affected by `packageJson.meteor.mainModule`. Only resources added by
compiler plugins via `addJavaScript` are subject to the new configuration
option. If a compiler plugin calls `addStylesheet` or `addHtml`, those
resources will still be included unconditionally in the HTML document
rendered by the web server. While you could try to import these resources
from JavaScript, you would only be importing any JavaScript resources the
compiler plugin registered using `addJavaScript`, and not the actual HTML
or CSS resources. I welcome feedback on this decision, but if there's no
meaningful way to import a resource, making it lazy just means it won't be
loaded at all.

An ulterior motive for this feature is to enable Meteor apps to have
directory layouts that developers who are not familiar with Meteor can
immediately understand. The special meaning of the `imports` directory and
the surprising eagerness of modules outside of `imports` have always
required some explanation, so this change should reduce that surprise.

Because Meteor strives to be a zero-configuration tool, this is currently
the only supported option in the "meteor" section of `package.json`,
though the available options may be expanded in the future if that's the
best/only way to solve important problems. This would involve adding
additional methods to the `MeteorConfig` class in `project-context.js`,
and then using those methods elsewhere in the `meteor/tools` codebase.
  • Loading branch information
Ben Newman committed Feb 23, 2018
1 parent bbd6149 commit ae3ad3b
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 23 deletions.
28 changes: 21 additions & 7 deletions tools/isobuild/compiler-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ class ResourceSlot {
return fileOptions && fileOptions[name];
}

_isLazy(options) {
_isLazy(options, isJavaScript) {
let lazy = this._getOption("lazy", options);

if (typeof lazy === "boolean") {
Expand All @@ -616,9 +616,23 @@ class ResourceSlot {
// test files should always be included, if we're running app
// tests.
return isInImports && !isTestFilePath(this.inputResource.path);
} else {
return isInImports;
}

if (isJavaScript &&
// Set in PackageSource#_inferFileOptions (in package-source.js)
// to indicate that there was a mainModule, but this module was
// not the mainModule. It's important to wait until this point to
// make the final call, because we can finally tell whether the
// file was JavaScript or not (non-JS resources are not affected
// by the meteor.mainModule configuration option).
this._getOption("mainModule") === false) {
return true;
}

// In other words, the imports directory remains relevant for non-JS
// resources, and for JS resources in the absence of an explicit
// meteor.mainModule configuration.
return isInImports;
}

addStylesheet(options) {
Expand All @@ -637,7 +651,7 @@ class ResourceSlot {
targetPath,
servePath: self.packageSourceBatch.unibuild.pkg._getServePath(targetPath),
hash: sha1(data),
lazy: this._isLazy(options),
lazy: this._isLazy(options, false),
};

if (useMeteorInstall && resource.lazy) {
Expand Down Expand Up @@ -711,7 +725,7 @@ class ResourceSlot {
sourceMap: options.sourceMap,
// intentionally preserve a possible `undefined` value for files
// in apps, rather than convert it into `false` via `!!`
lazy: self._isLazy(options),
lazy: self._isLazy(options, true),
bare: !! self._getOption("bare", options),
mainModule: !! self._getOption("mainModule", options),
});
Expand Down Expand Up @@ -739,7 +753,7 @@ class ResourceSlot {
servePath: self.packageSourceBatch.unibuild.pkg._getServePath(
options.path),
hash: sha1(options.data),
lazy: self._isLazy(options),
lazy: self._isLazy(options, false),
});
}

Expand All @@ -763,7 +777,7 @@ class ResourceSlot {
self.outputResources.push({
type: options.section,
data: Buffer.from(files.convertToStandardLineEndings(options.data), 'utf8'),
lazy: self._isLazy(options),
lazy: self._isLazy(options, false),
});
}

Expand Down
2 changes: 1 addition & 1 deletion tools/isobuild/package-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function toArchArray (arch) {
// 'client' -> 'web'
// 'server' -> 'os'
// '*' -> '*'
function mapWhereToArch (where) {
export function mapWhereToArch(where) {
if (where === 'server') {
return 'os';
} else if (where === 'client') {
Expand Down
73 changes: 58 additions & 15 deletions tools/isobuild/package-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,10 @@ _.extend(PackageSource.prototype, {
constraint: constraint.constraintString });
});

var projectWatchSet = projectContext.getProjectWatchSet();
const projectWatchSet = projectContext.getProjectWatchSet();
const mainModulesByArch =
projectContext.meteorConfig.getMainModulesByArch();
projectWatchSet.merge(projectContext.meteorConfig.watchSet);

_.each(compiler.ALL_ARCHES, function (arch) {
// We don't need to build a Cordova SourceArch if there are no Cordova
Expand All @@ -847,6 +850,9 @@ _.extend(PackageSource.prototype, {
return;
}

const mainModule = projectContext.meteorConfig
.getMainModuleForArch(arch, mainModulesByArch);

// XXX what about /web.browser/* etc, these directories could also
// be for specific client targets.

Expand All @@ -867,20 +873,42 @@ _.extend(PackageSource.prototype, {
isApp: true,
};

// If this architecture has a mainModule defined in
// package.json, it's an error if _findSources doesn't find that
// module. If no mainModule is defined, anything goes.
let missingMainModule = !! mainModule;

const sources = self._findSources(findOptions).sort(
loadOrderSort(sourceProcessorSet, arch)
).map(relPath => {
if (relPath === mainModule) {
missingMainModule = false;
}

const fileOptions = self._inferFileOptions(relPath, {
arch,
isApp: true,
mainModule,
});

return {
relPath,
fileOptions,
};
});

if (missingMainModule) {
buildmessage.error([
"Could not find mainModule for '" + arch + "' architecture: " + mainModule,
'Check the "meteor" section of your package.json file?'
].join("\n"));
}

const assets = self._findAssets(findOptions);

return {
sources: self._findSources(findOptions).sort(
loadOrderSort(sourceProcessorSet, arch)
).map(relPath => {
return {
relPath,
fileOptions: self._inferFileOptions(relPath, {
arch,
isApp: true,
}),
};
}),

assets: self._findAssets(findOptions),
sources,
assets,
};
}
});
Expand Down Expand Up @@ -919,7 +947,11 @@ _.extend(PackageSource.prototype, {
}
}),

_inferFileOptions(relPath, {arch, isApp}) {
_inferFileOptions(relPath, {
arch,
isApp,
mainModule,
}) {
const fileOptions = {};
const isTest = global.testCommandMetadata
&& global.testCommandMetadata.isTest;
Expand Down Expand Up @@ -974,6 +1006,17 @@ _.extend(PackageSource.prototype, {
}
}

if (isApp && mainModule) {
if (relPath === mainModule) {
fileOptions.lazy = false;
fileOptions.mainModule = true;
} else if (typeof fileOptions.lazy === "undefined") {
// Used in ResourceSlot#_isLazy (in compiler-plugin.js) to make a
// final determination of whether the file should be lazy.
fileOptions.mainModule = false;
}
}

return fileOptions;
},

Expand Down
94 changes: 94 additions & 0 deletions tools/project-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ var watch = require('./fs/watch.js');
var Profile = require('./tool-env/profile.js').Profile;
import { KNOWN_ISOBUILD_FEATURE_PACKAGES } from './isobuild/compiler.js';

import {
optimisticReadJsonOrNull,
optimisticHashOrNull,
} from "./fs/optimistic.js";

import {
mapWhereToArch,
} from "./isobuild/package-api.js";

// The ProjectContext represents all the context associated with an app:
// metadata files in the `.meteor` directory, the choice of package versions
// used by it, etc. Any time you want to work with an app, create a
Expand Down Expand Up @@ -363,6 +372,13 @@ _.extend(ProjectContext.prototype, {
});
if (buildmessage.jobHasMessages())
return;

self.meteorConfig = new MeteorConfig({
packageJsonPath: files.pathJoin(self.projectDir, "package.json")
});
if (buildmessage.jobHasMessages()) {
return;
}
});

self._completedStage = STAGE.READ_PROJECT_METADATA;
Expand Down Expand Up @@ -1580,3 +1596,81 @@ _.extend(exports.FinishedUpgraders.prototype, {
files.appendFile(self.filename, appendText);
}
});

export class MeteorConfig {
constructor({
packageJsonPath,
}) {
this.packageJsonPath = packageJsonPath;
this.watchSet = new watch.WatchSet;
}

_ensureInitialized() {
if (! _.has(this, "config")) {
const json = optimisticReadJsonOrNull(this.packageJsonPath);
this.config = json && json.meteor || null;
this.watchSet.addFile(
this.packageJsonPath,
optimisticHashOrNull(this.packageJsonPath)
);
}

return this.config;
}

// General utility for querying the "meteor" section of package.json.
// TODO Implement an API for setting these values?
get(...keys) {
let config = this._ensureInitialized();
if (config) {
keys.every(key => {
if (config && _.has(config, key)) {
config = config[key];
return true;
}
});
return config;
}
}

// Call this first if you plan to call getMainModuleForArch multiple
// times, so that you can avoid repeating this work each time.
getMainModulesByArch(arch) {
const configMainModule = this.get("mainModule");
const mainModulesByArch = Object.create(null);

if (configMainModule) {
if (typeof configMainModule === "string") {
// If packageJson.meteor.mainModule is a string, use that string
// as the mainModule for all architectures.
mainModulesByArch["os"] = configMainModule;
mainModulesByArch["web"] = configMainModule;
} else if (typeof configMainModule === "object") {
// If packageJson.meteor.mainModule is an object, use its
// properties to select a mainModule for each architecture.
Object.keys(configMainModule).forEach(where => {
mainModulesByArch[mapWhereToArch(where)] =
files.pathNormalize(configMainModule[where]);
});
}
}

return mainModulesByArch;
}

// Given an architecture like web.browser, get the best mainModule for
// that architecture. For example, if this.config.mainModule.client is
// defined, then because mapWhereToArch("client") === "web", and "web"
// matches web.browser, return this.config.mainModule.client.
getMainModuleForArch(
arch,
mainModulesByArch = this.getMainModulesByArch(),
) {
const mainMatch = archinfo.mostSpecificMatch(
arch, Object.keys(mainModulesByArch));

if (mainMatch) {
return mainModulesByArch[mainMatch];
}
}
}

0 comments on commit ae3ad3b

Please sign in to comment.