ajvincent / es-membrane

An ECMAScript implementation of a Membrane, allowing users to dynamically hide, override, or extend objects in JavaScript with controlled effects on the original objects.
ISC License
109 stars 13 forks source link

Build Status

The concepts driving a membrane

Suppose you have a set of JavaScript-based constructors and prototypes. You've built it out, tested it, and ensured it works correctly. But you don't necessarily trust that other people will use your API as you intended. They might try to access or overwrite member properties you want to keep private, for example. Or they might replace some of your methods with others. While you can't keep people from forking your code base, at runtime you have some options.

The simplest option is to freeze what you can, so that certain values can't be changed:

Object.freeze(MyBase.prototype);

Another, older technique would be to return one object that maps to another:

function PrivateFoo() {
  // ...
}
PrivateFoo.prototype = {
  // ...
}

function Foo(arg0, arg1, arg2) {
  var pFoo = new PrivateFoo(arg0, arg1, arg2);
  return {
    method1: function() { return pFoo.method1.apply(pFoo, arguments); },
    get property2: () {
      return pFoo.property2;
    },
    // etc., etc.
  };
}

This is called a closure, but it's a painful way of hiding data, plus it's not very scalable. You could make it work, at great effort...

What people really wanted, though, begins with the concept of a proxy. By this, I do not mean a networking proxy, but a proxy to a JavaScript object or function. This type of proxy allows you to represent an object, but change the rules for looking up properties, setting them, or executing functions on-the-fly. For example, if I wanted an object to appear to have an extra property "id", but not actually give that object the property, I could use a proxy, as follows:

var handler = {
  get: function(target, propName, receiver) {
    if (propName === "id") {
      return 3;
    }

    return Reflect.get(target, propName, receiver);
  }
};

var x = {}; // a vanilla object

var p = new Proxy(x, handler);

p.id // returns 3
x.id // returns undefined

All well and good. Next, suppose you want to return a reference to an object from x:

// ...
var x = new xConstructor();
x.y = { x: x };

var p = new Proxy(x, handler);
p.y; // returns x.y

Uh-oh. x.y is not in a proxy at all: we (and everyone else) has full access through y to whatever y implements. We'd be better off if p.y was itself a proxy.

The good news is that a proxy can easily return another proxy. In this case, the getOwnPropertyDescriptor "trap" implemented on the handler would replace the value property of the object with a proxy.

So let's suppose that we did that. All right:

// ...
var x = new xConstructor();
x.y = { x: x };

var p = new Proxy(x, handler);
p.y; // returns Proxy(x.y, handler);
p.y.x; // returns Proxy(x.y.x, handler);
p.y.x === p; // returns false
p.y.x === p.y.x; // returns false

x.y.x === x; // returns true

Uh-oh again. The x.y.x value is a cyclic reference that refers back to the original x. But that identity property is not preserved for p, which is a proxy of x... at least, not with the simplest "getOwnPropertyDescriptor" trap. No, we need something that stores a one-to-one relationship between p and x... and for that matter defines one-to-one relationships between each natural object reachable from x and their corresponding proxy reachable from p.

This is where a WeakMap comes in. A WeakMap is an object that holds references from key objects (non-primitives) to other values. The important distinction between a WeakMap and an ordinary JavaScript object {} is that the keys in a WeakMap can be objects, but an ordinary JS object only allows strings for its keys.

At this point, logically, you have two related sets, called "object graphs". The first object graph, starting with x, is a set of objects which are related to each other, and reachable from x. The second object graph, starting with p, is a set of proxies, each of which matches in a one-to-one relationship with an object found in that first object graph.

The "membrane" is the collection of those two object graphs, along with the rules that determine how to transform a value from one object graph to another.

So a WeakMap can establish that one-to-one relationship. But you still need a little more information. You might think this would suffice:

var map = new WeakMap();
map.set(x, p);
map.set(p, x);

Not quite. All this tells you is that x refers to p, and p refers to x. But you don't know from this alone which object graph x belongs to, and which object graph p belongs to. So there's one more concept to introduce, where both x and p point to a shared, common object that references each by the name of their respective object graphs:

var map = new WeakMap();
var subMapFor_x = {
  "original": x,
  "proxy": p
};
map.set(x, subMapFor_x);
map.set(p, subMapFor_x);

Finally, you have enough information to uniquely identify x by both its object graph and by how we got there. Likewise, we can, through the WeakMap and the "sub-map" it stores, identify an unique proxy p in the "proxy" object graph that matches to x in the "original" object graph.

(In this module's implementation, the "sub-map" is called a ProxyMapping, and has a ProxyMapping constructor and prototype implementation.)

Additional reading

Glossary of terms

Configuring a membrane: the GUI configuration tool

https://ajvincent.github.io/es-membrane/gui/index.html

This library has a HTML-based configuration tool (source at docs/gui) which allows you to set up your basic object graphs and several common distortions for white-listing by default. The tool will, based on your inputs, generate both a membrane construction JavaScript file and a reusable JSON file for editing the configuration later. The process is straight-forward, if complex:

  1. Load the source files defining your constructors and/or classes.
    • If you have saved the configuration JSON file before, re-attach it here.
  2. Click the Membrane tab to set up the Membrane's base configuration (object graph names, objects unconditionally passed through)
  3. Click the Continue button to populate the graph-specific configuration pages and the Output tab.
    • This will create two new sets of tabs: one for your objects to configure (on a per-graph basis), and one for whether you are editing the value itself, its prototype, or direct instances of an invoked constructor.
  4. The Output tab has two hyperlinks to directly save the configuration file and membrane creation file, respectively.

How to use the es-membrane module

  1. Define the object graphs by name you wish to use. Examples:
    • [ "wet", "dry" ] or [ "wet", "dry", "damp"], per Tom van Cutsem
    • [ "trusted", "sandboxed" ]
    • [ "private", "public" ]
  2. Create an instance of Membrane.
    • The constructor for Membrane takes an optional options object.
    • If options.showGraphName is true (or "truthy" in the JavaScript sense), each ObjectGraphHandler instance will expose an additional "membraneGraphName" property for proxies created from it.
    • This is more for debugging purposes than anything else, and should not be turned on in a Production environment.
    • If options.logger is defined, it is presumed to be a log4javascript-compatible logger object for the membrane to use.
  3. Ask for an ObjectGraphHandler from the membrane, by a name as a string. This will be where "your" objects live.
  4. Ask for another ObjectGraphHandler from the membrane, by a different object graph name. This will be where "their" objects live.
  5. (Optional) Use the .addProxyListener() method of the ObjectGraphHandler, to add listeners for the creation of new proxies.
  6. Add a "top-level" object to "your" ObjectGraphHandler instance.
  7. Ask the membrane to get a proxy for the original object from "their" object graph, based on the graph name.
  8. (Optional) Use the membrane's modifyRules object to customize default behaviors of individual proxies.
  9. Repeat steps 5 through 7 for any additional objects that need special handling.
    • Example: Prototypes of constructors, which is where most property lookups go.
  10. Return "top-level" proxies to objects, from "their" object graph, to the end-user.
    • DO NOT return the Membrane, or any ObjectGraphHandler. Returning those allows others to change the rules you so carefully crafted.

Example code:

/* The object graph names I want are "dry" and "wet".
 * "wet" is what I own.
 * "dry" is what I don't trust.
 */

// Establish the Membrane.
var dryWetMB = new Membrane({
  // These are configuration options.
});

// Establish "wet" ObjectGraphHandler.
var wetHandler = dryWetMB.getHandlerByName("wet", { mustCreate: true });

// Establish "dry" ObjectGraphHandler.
var dryHandler = dryWetMB.getHandlerByName("dry", { mustCreate: true });

// Establish "wet" view of an object.
// Get a "dry" view of the same object.
var dryDocument = dryWetMB.convertArgumentToProxy(
  wetHandler,
  dryHandler,
  wetDocument
);
// dryDocument is a Proxy whose target is wetDocument, and whose handler is dryHandler.

// Return "top-level" document proxy.
return dryDocument;

This will give the end-user a very basic proxy in the "dry" object graph, which also implements the identity and property lookup rules of the object graph and the membrane. In fact, it is a perfect one-to-one correspondence: because no special proxy traps are established in steps 7 and 8 above, any and all operations on the "dry" document proxy, or objects and functions retrieved through that proxy (directly or indirectly) will be reflected and repeated on the corresponding "wet" document objects exactly with no side effects. (Except possibly those demanded through the Membrane's configuration options, such as providing a logger.)

Such a membrane is, for obvious reasons, useless. But this perfect mirroring has to be established first before anyone can customize the membrane's various proxies, and thus, rules for accessing and manipulating objects. It is through custom proxies whose handlers inherit from ObjectGraphHandler instances in the membrane that you can achieve proper hiding of properties, expose new properties, and so on.

Modifying the proxy behavior: The ModifyRules API

Every membrane has a .modifyRules object which allows developers to modify how an individual proxy behaves.

The .modifyRules object has several public methods:

Proxy listeners: Reacting to new proxies

When the membrane creates a new proxy and is about to return it to a caller, there is one chance to change the rules for that proxy before the caller ever sees it. This is through the proxy listener API.

Each object graph handler has two methods:

The callbacks are executed in the order they were added, with a single object argument. This "meta" object has several methods and properties:

An exception accidentally thrown from a proxy listener will not stop iteration:

That's why the throwException() method exists: to make it clear that you intended to throw the exception outside the membrane.

How the Membrane actually works

Each object graph's objects and functions, then, only see three different types of values:

  1. Primitive values
  2. Objects and functions passed into the membrane from that object graph's objects and functions
  3. Proxies from other object graphs, representing native objects and functions belonging to those other object graphs.

For instance, if I have a "dry" proxy to a function from the "wet" object graph and I call the proxy as a function, the "wet" function will be invoked only with primitives, objects and functions known to the "wet" graph, and "dry" proxies. Each argument (and the "this" object) is counter-wrapped in this way, so that the "wet" function only sees values it can rely on being in the "wet" object graph (including "wet" proxies to "dry" objects and callback functions).

As long as all the proxies (and their respective handlers) follow the above rules, in addition to how they manipulate the appearance (or disappearance) of properties of those proxies, the membrane will be able to correctly preserve each object graph's integrity. Which is the overall goal of the membrane: keep objects and functions from accidentally crossing from one object graph to another.