Extending JavaScript with _package_

Package management in JavaScript

JavaScript does not provide any package management features. There is no standard way to specify that one module of code depends upon other modules being available. All external JavaScript (.js) files required for an application must be loaded directly by the host web page. Unless we do something clever, this means that any changes to the dependenices of a .js file requires us to change all of its users!

Fortunately, JavaScript does allow us to be clever and build a package system. This crucially depends upon JavaScript's support for higher-order and anonymous functions, as well as its ability to interact with the host HTML document via the HTML DOM (Document Object Model).

Here is an example of a couple of packages we may wish to specify:

// Code in SomePackage.js:
var someFunction;
_package_('SomePackage', [], function () {
	// This should be package-local
	function helperFunction(x, y, z) {
		return x*y + y*z;
	}

	// This should be exported in the global namespace
	someFunction = function(x, y) {
		return helperFunction(x, y, 5);
	}
});

// Code in OtherPackage.js:
var otherObject;
_package_('OtherPackage', ['SomePackage'], function () {
// SomePackage should be loaded before this code runs.

	// This should be package-local
	var store = { };

	// This should be exported in the global namespace
	otherObject = {
		put: function (name, x, y) {
			// Uses a required import
			store[name] = someFunction(x, y);
		},
		get: function (name) {
			return store[name];
		}
	};
});

Defining an include function

Before we get to packages with dependencies, let's see how to load external scripts into a JavaScript program running on a web page.

// Exports
var include;

// Avoid global namespace pollution
(function () {

	// We will insert additional scripts into the <head> section of the HTML document.
	var head = document.getElementsByTagName('head')[0];

	// Load an external script into the document.
	include = function (jsUrl, k) {
		// Create a tag to load the specified script.
		// It will have the form <script type="text/javascript" src="jsUrl">...</script>
		var scriptTag    = document.createElement('script');
		scriptTag.type   = 'text/javascript';
		scriptTag.src    = jsUrl;
		scriptTag.onload = k;

		// Add the script to the document, causing it to load and run asynchronously.
		head.appendChild(scriptTag);
	};
})();

This program has a few interesting features:

  1. The local variable head is hidden within an anonymous closure, to avoid global namespace pollution.
  2. The include function is bound to a pre-existing global variable. If we had instead declared it as a named function, it would be bound to a new local variable.
  3. We dynamically create a new <script> tag using the HTML DOM and insert it into the host document, to load an external script.

A surprising feature of (3) is that we can load external scripts from any site—not just the same origin as the HTML document! This means JavaScript can send information to any web server (via parameters within the requested script's jsUrl), and retrieve information back (via the effects of evaluating the script)!

Requiring a package

Now that we know how to load an arbitrary script, the next step is a mechanism to keep track of who required a script to be loaded. We would like to be able to call:

requirePackage("PackageName", "http://www.somehost.com/js/some-script.js", function () { ... })

and have the code ... execute after the package called PackageName has been loaded from the file some-script.js

The basic idea is to queue up the continuations provided as the third argument to requirePackage(), and only evaluate them once the package has been loaded from the specified script file. There are three possible states when we call requirePackage():

  1. PackageName has already been loaded.
  2. some-script.js has been included, but PackageName has not yet finished loading (e.g., due to network latency).
  3. We have not yet even tried to load PackageName.

Here is the code of requirePackage(). It assumes include() is available.

// Exports
var requirePackage;

// Avoid namespace pollution
(function () {
	// Keep track of the packages that have been loaded.
	var includedPackages = { };

	// Call k() after loading the named package from the given url.
	requirePackage = function (name, url, k) {
		if (includedPackages[name] == true) {
		// The package has already been loaded.
			if (k != null) {
				// Proceed immediately.
				k();
			}
		} else if (includedPackages[name]) {
		// The package is waiting on its dependencies.
			if (k != null) {
				// Ask the package to call us when it is ready.
				includedPackages[name].push(k);
			}
		} else {
		// The package has not been visited yet, so start processing it.
			includedPackages[name] = [];
			if (k != null) {
				// Ask the package to call us when it is ready.
				includedPackages[name].push(k);
			}
			// Load the script at the given url.
			// When it runs, if it contains the requested package, evaluation will continue.
			include(url);
		}
	}
})();

Defining a package

Finally, we need a way to define packages. A package must automatically load all its dependencies and wait for them (via requirePackage()), and it must notify all packages that depend upon it once it is done loading. Here is the code. Again, this code fragment assumes requirePackage() and include() have already been defined.

// Exports
var _package_;

// Avoid namespace pollution
(function () {
	_package_ = function (name, deps, k) {
		var kprime = join(deps.length, function () {
		// Run this code when all our dependencies are loaded.

			// Evaluate the package contents.
			if (k != null) {
				k();
			}

			// Notify all packages which depend on us that we're done loading.
			var clients = includedPackages[name];
			includedPackages[name] = true;
			if (clients && clients != true) {
				for (var i = 0; i < clients.length; i++) {
					clients[i]();
				}
			}
		});

		// Begin loading dependencies.
		for (var i = 0; i < deps.length; i++) {
			// Expand syntactic shortcuts
			if (typeof(deps[i]) == 'string') {
				deps[i] = [deps[i]];
			}
			if (deps[i].length == 1) {
				deps[i].push(deps[i][0]+'.js');
			}

			// General case
			if (deps[i].length == 2) {
				requirePackage(deps[i][0], deps[i][1], kprime);
			}
		}
	};
})();

All the code

The above code fragments stored include() and requirePackage() in the global namespace, although they are really just internal implementation details used by the _package_ construct. We can combine all of the code together as follows. You can also download this code as package.js.

// Exports
var _package_;

// Avoid namespace pollution
(function () {

	// We will insert additional scripts into the <head> section of the HTML document.
	var head = document.getElementsByTagName('head')[0];

	// Load an external script into the document.
	function include(jsUrl, k) {
		// Create a tag to load the specified script.
		// It will have the form <script type="text/javascript" src="jsUrl">...</script>
		var scriptTag    = document.createElement('script');
		scriptTag.type   = 'text/javascript';
		scriptTag.src    = jsUrl;
		scriptTag.onload = k;

		// Add the script to the document, causing it to load and run asynchronously.
		head.appendChild(scriptTag);
	};

	// Keep track of the packages that have been loaded.
	var includedPackages = { };

	// Call k() after loading the named package from the given url.
	function requirePackage(name, url, k) {
		if (includedPackages[name] == true) {
		// The package has already been processed.
			if (k != null) {
				// Proceed immediately.
				k();
			}
		} else if (includedPackages[name]) {
		// The package is waiting on its dependencies.
			if (k != null) {
				// Ask the package to call us when it is ready.
				includedPackages[name].push(k);
			}
		} else {
		// The package has not been visited yet, so start processing it.
			includedPackages[name] = [];
			if (k != null) {
				// Ask the package to call us when it is ready.
				includedPackages[name].push(k);
			}
			// Load the script at the given url.
			// When it runs, if it contains the requested package, evaluation will continue.
			include(url);
		}
	}

	// Declare a new package and its dependencies.
	_package_ = function (name, deps, k) {
		var kprime = join(deps.length, function () {
		// Run this code when all our dependencies are loaded.

			// Evaluate the package contents.
			if (k != null) {
				k();
			}

			// Notify all packages which depend on us that we're done loading.
			var clients = includedPackages[name];
			includedPackages[name] = true;
			if (clients && clients != true) {
				for (var i = 0; i < clients.length; i++) {
					clients[i]();
				}
			}
		});

		// Begin loading dependencies.
		for (var i = 0; i < deps.length; i++) {
			// Expand syntactic shortcuts
			if (typeof(deps[i]) == 'string') {
				deps[i] = [deps[i]];
			}
			if (deps[i].length == 1) {
				deps[i].push(deps[i][0]+'.js');
			}

			// General case
			if (deps[i].length == 2) {
				requirePackage(deps[i][0], deps[i][1], kprime);
			}
		}
	};
})();