Skip to content

Commit

Permalink
Implement a useNpm directive for packages
Browse files Browse the repository at this point in the history
  • Loading branch information
avital authored and glasser committed Mar 19, 2013
1 parent ecce7f0 commit 5352db9
Show file tree
Hide file tree
Showing 15 changed files with 641 additions and 162 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ cache
TAGS
*.log
*.out
npm-debug.log
64 changes: 54 additions & 10 deletions lib/bundler.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,38 @@ var PackageInstance = function (pkg, bundle) {
});
},

// Called when this package wants to depend on node
// modules for server code.
//
// @param npmDependencies {Object} eg {gcd: "0.0.0", tar: "0.1.14"}
useNpm: function (npmDependencies) {
var meteorNpm = require(path.join(__dirname, 'meteor_npm.js'));

if (this.npmDependencies)
throw new Error("Can only call `useNpm` once.");
this.npmDependencies = npmDependencies;

// don't allow npm fuzzy versions so that there is complete
// consistency when deploying a meteor app
//
// XXX use something like seal or lockdown to have *complete* confidence
// we're running the same code?
meteorNpm.ensureOnlyExactVersions(npmDependencies);

// create a package .npm subdirectory for npm bookkeeping and node_modules
var packageNpmDir = meteorNpm.ensurePackageNpmDir(self.pkg.source_root);

// go through a specialized npm dependencies update process, ensuring
// we don't get new versions of any (sub)dependencies
meteorNpm.updateDependencies(packageNpmDir, npmDependencies);

// map the generated node_modules directory to the package directory
// within the bundle
var nodeModulesPath = path.join(packageNpmDir, 'node_modules');
var relNodeModulesPath = ['packages', self.pkg.name, 'node_modules'].join('/');
self.bundle.serverExternalDirs[relNodeModulesPath] = nodeModulesPath;
},

add_files: function (paths, where) {
if (!(paths instanceof Array))
paths = paths ? [paths] : [];
Expand Down Expand Up @@ -215,8 +247,8 @@ var Bundle = function () {
// Packages that have had tests included. Map from package id to instance
self.tests_included = {};

// release manifest
self.manifest = null;
// meteor release manifest
self.releaseManifest = null;

// map from environment, to list of filenames
self.js = {client: [], server: []};
Expand All @@ -239,6 +271,11 @@ var Bundle = function () {
// the individual input files that were combined.
self.manifest = [];

// these directories are copied (cp -r) without any intervention, eg
// for node modules. maps target path (server relative) to
// source directory on disk
self.serverExternalDirs = {};

// list of segments of additional HTML for <head>/<body>
self.head = [];
self.body = [];
Expand Down Expand Up @@ -386,7 +423,7 @@ _.extend(Bundle.prototype, {

includeTests: function (packageName) {
var self = this;
var pkg = packages.get(self.manifest, packageName);
var pkg = packages.get(self.releaseManifest, packageName);
if (self.tests_included[pkg.id])
return;
self.tests_included[pkg.id] = true;
Expand Down Expand Up @@ -613,9 +650,16 @@ _.extend(Bundle.prototype, {
for (var rel_path in self.files.server) {
var path_in_bundle = path.join('app', rel_path);
var full_path = path.join(build_path, path_in_bundle);
app_json.load.push(path_in_bundle);
files.mkdir_p(path.dirname(full_path), 0755);
fs.writeFileSync(full_path, self.files.server[rel_path]);
app_json.load.push(path_in_bundle);
}

// `node_modules` directories for packages
for (var rel_path in self.serverExternalDirs) {
var path_in_bundle = path.join('app', rel_path);
var full_path = path.join(build_path, path_in_bundle);
files.cp_r(self.serverExternalDirs[rel_path], full_path);
}

var app_html = self._generate_app_html();
Expand Down Expand Up @@ -725,21 +769,21 @@ exports.bundle = function (app_dir, output_path, options) {
packages.flush();

var bundle = new Bundle;
var manifest;
var releaseManifest;
if (options.versionOverride)
manifest = packages.manifestForReleaseVersion(options.versionOverride);
releaseManifest = packages.manifestForReleaseVersion(options.versionOverride);
else
manifest = packages.manifestForProject(app_dir);
releaseManifest = packages.manifestForProject(app_dir);

if (!manifest) {
if (!releaseManifest) {
// XXX We should instead use the latest version installed, and
// notify the user.
// https://app.asana.com/0/2604247562419/2765125200674
console.log("Couldn't find .meteor/version -- only searching local packages.");
}
bundle.manifest = manifest;
bundle.releaseManifest = releaseManifest;

// our manifest is set, let's now load the app
// our release manifest is set, let's now load the app
var app = packages.get_for_app(app_dir, ignore_files);
bundle.use(app);

Expand Down
3 changes: 1 addition & 2 deletions lib/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ var files = module.exports = {
return ret;
},


// given a path, returns true if it is a meteor application (has a
// .meteor directory with a 'packages' file). false otherwise.
is_app_dir: function (filepath) {
Expand Down Expand Up @@ -248,7 +247,7 @@ var files = module.exports = {
file = path.join(p, file);
files.rm_recursive(file);
});
fs.rmdirSync(p)
fs.rmdirSync(p);
} else
fs.unlinkSync(p);
},
Expand Down
210 changes: 210 additions & 0 deletions lib/meteor_npm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
var semver = require('semver');
var exec = require('child_process').exec;
var Future = require('fibers/future');

var path = require('path');
var fs = require('fs');
var files = require(path.join(__dirname, 'files.js'));
var _ = require('underscore');

var meteorNpm = module.exports = {
ensureOnlyExactVersions: function(npmDependencies) {
_.each(npmDependencies, function(version, name) {
if (!semver.valid(version))
throw new Error(
"Must declare exact version of npm package dependency: " + name + '@' + version);
});
},

// Ensure a package has a well-structured .npm subdirectory.
//
// @returns {String} path to said .npm subdirectory
ensurePackageNpmDir: function(packageDir) {
var packageNpmDir = path.join(packageDir, '.npm');

if (fs.existsSync(packageNpmDir)) {
// upgrade npm dependencies
if (!fs.statSync(packageNpmDir).isDirectory()) {
throw new Error("Should be a directory: " + packageNpmDir);
}
} else {
console.log('npm: creating ' + packageNpmDir);
files.mkdir_p(packageNpmDir);

// we recreate package.json each time we bundle, based on the
// arguments to useNpm. similarly, we recreate
// npm-shrinkwrap.json from meteor-npm-shrinkwrap.json, with
// some modifications. at the end of the bundling process we
// remove thes files but in case we crashed mid-way we make
// sure they're gitignored.
//
// node_modules shouldn't be in git since we recreate it as
// needed by using `npm install`. since we use `npm
// shrinkwrap` we're guarenteed to have the same version
// installed each time.
fs.writeFileSync(path.join(packageNpmDir, '.gitignore'),
['package.json', 'npm-shrinkwrap.json', 'node_modules'].join('\n'));
}

return packageNpmDir;
},

// @param npmDependencies {Object} dependencies that should be installed,
// eg {tar: '0.1.6', gcd: '0.0.0'}
updateDependencies: function(packageNpmDir, npmDependencies) {
var self = this;

// prepare .npm dir from which we'll be calling out to npm, and
// compute dependencies that need to be updated
var dependenciesToUpdate = this._prepareForUpdate(packageNpmDir, npmDependencies);

// if we have a shrinkwrap file, call `npm install`.
if (fs.existsSync(path.join(packageNpmDir, 'npm-shrinkwrap.json'))) {
if (!fs.existsSync(path.join(packageNpmDir, 'node_modules'))) {
console.log('installing shrinkwrapped npm dependencies into ' + packageNpmDir);
} else {
// just calling `npm install` to make sure we have all of the
// node_modules we should. eg if you ran this package before
// new dependencies were added and then you took a new
// version.
}
self._installFromShrinkwrap(packageNpmDir);
}

// install modified dependencies
if (!_.isEmpty(dependenciesToUpdate)) {
process.stdout.write(
'installing npm dependencies ' + this._depsToString(dependenciesToUpdate) +
' into ' + packageNpmDir + '... ');

_.each(dependenciesToUpdate, function(version, name) {
self._installNpmModule(name, version, packageNpmDir);
});

// before shrinkwrapping we need to delete unused `node_modules` directories
_.each(fs.readdirSync(path.join(packageNpmDir, 'node_modules')), function(installedModule) {
if (!npmDependencies[installedModule]) {
files.rm_recursive(path.join(packageNpmDir, 'node_modules', installedModule));
}
});

// shrinkwrap
self._shrinkwrap(packageNpmDir);
process.stdout.write("DONE\n");
}

this._updateComplete(packageNpmDir);
},

// Prepare a package .npm directory for installing new packages and/or
// new versions of packages:
//
// - Copies meteor-npm-shrinkwrap.json into npm-shrinkwrap.json
// while removing the parts related to packages being upgraded.
//
// - Creates a package.json file corresponding to the packages that
// - are to be installed (needed for both `npm install` and `npm shrinkwrap`)
//
// XXX doesn't support uninstalling packages
//
// @param npmDependencies {Object} dependencies that should be installed,
// eg {tar: '0.1.6', gcd: '0.0.0'}
// @returns {Object} dependencies to update, eg {tar: '0.1.6'}
_prepareForUpdate: function(packageNpmDir, npmDependencies) {
//
// construct package.json
//
var packageJsonContents = JSON.stringify({
// name and version are unimportant but required for `npm install`
name: 'packages',
version: '0.0.0',
dependencies: npmDependencies
});
var packageJsonPath = path.join(packageNpmDir, 'package.json');
// this file will be removed in `_updateComplete`, but it's also .gitignored
fs.writeFileSync(packageJsonPath, packageJsonContents);


//
// meteor-npm-shrinkwrap.json -> npm-shrinkwrap.json, compute dependenciesToUpdate
//
var meteorShrinkwrapJsonPath = path.join(packageNpmDir, 'meteor-npm-shrinkwrap.json');
if (fs.existsSync(meteorShrinkwrapJsonPath)) {
var shrinkwrap = JSON.parse(fs.readFileSync(meteorShrinkwrapJsonPath));
dependenciesToUpdate = {};
_.each(npmDependencies, function(version, name) {
if (!shrinkwrap.dependencies[name] || shrinkwrap.dependencies[name].version !== version) {
dependenciesToUpdate[name] = version;
delete shrinkwrap.dependencies[name];
}
});

// this file will be removed in `_shrinkwrap` or `_updateComplete`, but it's also .gitignored
fs.writeFileSync(path.join(packageNpmDir, 'npm-shrinkwrap.json'),
JSON.stringify(shrinkwrap));
} else {
dependenciesToUpdate = npmDependencies;
}

return dependenciesToUpdate;
},

_updateComplete: function(packageNpmDir) {
var npmShrinkwrapJsonPath = path.join(packageNpmDir, 'npm-shrinkwrap.json');
if (fs.existsSync(npmShrinkwrapJsonPath)) // if we didn't update any dependencies
fs.unlinkSync(npmShrinkwrapJsonPath);

var packageJsonPath = path.join(packageNpmDir, 'package.json');
fs.unlinkSync(packageJsonPath);
},

_execSync: function(cmd, opts) {
return Future.wrap(function(cb) {
exec(cmd, opts, function (err, stdout, stderr) {
var result = {stdout: stdout, stderr: stderr};
if (err)
_.extend(err, result);
cb(err, result);
});
})().wait();
},

_installNpmModule: function(name, version, dir) {
// We don't use npm.commands.install since we couldn't
// figure out how to silence all output (specifically the
// installed tree which is printed out with `console.log`)
this._execSync(path.join(files.get_dev_bundle(), "bin", "npm") + " install "
+ name + "@" + version,
{cwd: dir});
},

_installFromShrinkwrap: function(dir) {
if (!fs.existsSync(path.join(dir, "npm-shrinkwrap.json")))
throw new Error("Can't call `npm install` without a npm-shrinkwrap.json file present");
// `npm install`, which reads npm-shrinkwrap.json
this._execSync(path.join(files.get_dev_bundle(), "bin", "npm") + " install",
{cwd: dir});
},

// shrinkwraps into meteor-npm-shrinkwrap.json
_shrinkwrap: function(dir) {
// We don't use npm.commands.shrinkwrap for two reasons:
// 1. As far as we could tell there's no way to completely silence the output
// (the `silent` flag isn't piped in to the call to npm.commands.ls)
// 2. In various (non-deterministic?) cases we observed the
// npm-shrinkwrap.json file not being updated
this._execSync(path.join(files.get_dev_bundle(), "bin", "npm") + " shrinkwrap",
{cwd: dir});

var meteorShrinkwrapJsonPath = path.join(dir, 'meteor-npm-shrinkwrap.json');
var npmShrinkwrapJsonPath = path.join(dir, 'npm-shrinkwrap.json');
fs.renameSync(npmShrinkwrapJsonPath, meteorShrinkwrapJsonPath);
},

_depsToString: function(dependenciesToUpdate) {
return _.map(dependenciesToUpdate, function(version, name) {
return name + '@' + version;
}).join(', ');
}
};

2 changes: 2 additions & 0 deletions lib/packages.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ _.extend(Package.prototype, {
var code = fs.readFileSync(fullpath).toString();
// \n is necessary in case final line is a //-comment
var wrapped = "(function(Package,require){" + code + "\n})";
// See #runInThisContext
//
// XXX it'd be nice to runInNewContext so that the package
// setup code can't mess with our globals, but objects that
// come out of runInNewContext have bizarro antimatter
Expand Down
Loading

0 comments on commit 5352db9

Please sign in to comment.