es-membrane

0.9.0 • Public • Published

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

  • Object Graph: A collection of objects (and proxies to objects) which relate to one another and are for direct access from a single JavaScript scope. The term comes from graph theory. In the context of membranes, each unique object within an object graph has a one-to-one relationship with a proxy to that object in a completely separate object graph. Most importantly, the references from an object to its child properties are copied in the proxy, but refer to equivalent properties in the proxy's object graph, not the original object's graph.
  • Membrane: A collection of object graphs, and the rules for managing and creating proxies within them.
  • "Wet", "Dry", "Damp", "Steamy": These are names we use for convenience to refer to object graphs within a sample membrane (or a test membrane). Otherwise, they have exactly the same meaning as "foo" or "bar" in computer programming. "Wet" and "dry" come from the original concept of a biological cell's membrane: "wet" refers to anything inside the membrane, "dry" refers to anything outside the membrane, and never shall the two meet. "Damp" and "steamy" are just two other object graph names, because we can have more than two object graphs in a Membrane.
  • ObjectGraphHandler: This is a special ProxyHandler implementation which forms the base for all membrane proxies. It is what really enforces the rules, and correct behavior, for membrane proxies.
  • ChainHandler: This is an object which inherits from a ObjectGraphHandler, for the purpose of overriding proxy traps.
  • Wrapped versus unwrapped: An unwrapped object is the raw object. A wrapped object is a membrane proxy to the unwrapped object.
  • Wrapping and counter-wrapping: Wrapping refers to creating a proxy for an object and adding the proxy to a different object graph. Counter-wrapping happens with function arguments and "this" arguments, where the proxy to a function is invoked as a function itself. In this case, each argument must be either unwrapped (if it belongs to the same object graph as the target function) or wrapped in a proxy (if it belongs to a different object graph) before the ObjectGraphHandler passes it into the function's object graph. This latter process I call "counter-wrapping", to indicate it is going from the foreign object graph back to the function's object graph. Otherwise, it is the same.
  • Shadow target: Every Proxy instance in ECMAScript requires a shadow target to form the base for the proxy. In es-membrane, this means the shadow target usually functions as a copy of the original object, with cached properties defined only as necessary. Shadow targets must be of the same primordial type as the object we're reflecting: to call a proxy as if it were a function, the proxy's shadow target must be a function. To make a proxy look like an array, the shadow target must be an array, and so on.
  • Distortion: A Membrane's proxies must always parallel the properties of the objects they mirror... unless there are explicit rules defined on the proxy where they don't. These rules I call "distortions", because they distort the image of the underlying object through the proxy. One example would be hiding properties of the underlying object.
  • ProxyMapping: This is a convenience object which stores the one-to-one relationship between an unwrapped object and its matching wrapped proxies. It is through a ProxyMapping instance that we may have more than two object graphs. The ProxyMapping also stores the distortions defined for each membrane proxy.
  • ChainHandler: This is a ProxyHandler which inherits, directly or indirectly, from an ObjectGraphHandler. The ObjectGraphHandler specifies default proxy traps. The ChainHandler allows us to override those traps as necessary, and provides frozen references to the parent ChainHandler (if there is one) and to the base ObjectGraphHandler it derives from.
  • ProxyListener: This is a special object which reacts to the creation of a Membrane proxy before it is returned to the caller that demanded the proxy. It is the one and only chance to customize the proxy's distortions, including recreating it or throwing a deliberate exception, before the requesting object graph receives its proxy.
  • Primordial: This basically means globals provided by the ECMAScript engine, and their properties: Object, Array, Function, String, Date, etc.

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.
  1. Click the Membrane tab to set up the Membrane's base configuration (object graph names, objects unconditionally passed through)
  2. 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.
  1. 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" ]
  1. 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.
  1. Ask for an ObjectGraphHandler from the membrane, by a name as a string. This will be where "your" objects live.
  2. Ask for another ObjectGraphHandler from the membrane, by a different object graph name. This will be where "their" objects live.
  3. (Optional) Use the .addProxyListener() method of the ObjectGraphHandler, to add listeners for the creation of new proxies.
  4. Add a "top-level" object to "your" ObjectGraphHandler instance.
  5. Ask the membrane to get a proxy for the original object from "their" object graph, based on the graph name.
  6. (Optional) Use the membrane's modifyRules object to customize default behaviors of individual proxies.
  7. Repeat steps 5 through 7 for any additional objects that need special handling.
  • Example: Prototypes of constructors, which is where most property lookups go.
  1. 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:

  • .createChainHandler(ObjectGraphHandler): Create a ProxyHandler inheriting from Reflect or an ObjectGraphHandler. The returned object will have two additional properties:
    • .nextHandler, for the handler you passed in,
    • .baseHandler, for the ObjectGraphHandler that the originated all handlers in the chain.
    • The idea is that you can use .nextHandler and .baseHandler for any custom-implemented traps on the new handler, to refer to existing behavior.
  • .replaceProxy(oldProxy, handler): Replace a proxy in the membrane with a new one based on a chained ObjectGraphHandler.
    • Use this method after modifying a chain handler to define your own rules for a proxy.
    • This method probably shouldn't be used directly - instead, use a proxy listener to override the proxy before the membrane returns it to a customer.
  • .storeUnknownAsLocal(fieldName, proxy): Require that any unknown properties be stored "locally", instead of propagating to the underlying object.
    • If the property name doesn't exist on the original object, the proxy itself keeps the property name and value.
    • The underlying object will only accept property descriptors for properties it has defined on itself.
    • You can call .storeUnknownAsLocal on the underlying object to require that all proxies store unknown properties locally.
    • This setting is inherited: any objects constructed with the proxy or its target in the prototype chain will also store unknown properties locally.
  • .requireLocalDelete(fieldName, proxy): Require that the deletion of a property is "local", that it does not propagate to the underlying object.
    • Similar to .storeUnknownAsLocal, except .requireLocalDelete works for the delete operator.
  • .filterOwnKeys(fieldName, proxy, filter): Applies a filter function for Reflect.ownKeys as it applies to properties defined on the underlying object.
    • The filter function is cached in the membrane, so that it applies to all non-local properties in the future.
    • The filter function also affects what .getOwnPropertyDescriptor() returns: if there isn't a local property defined, and the filter rejects the property name, the property is considered undefined.
    • You can call .filterOwnKeys on the underlying object to require that all proxies apply the filter.
    • You can apply two filters to each proxy, one locally and one global (on the underlying object).
    • Local property definitions (or deletes) override the filter(s).
    • See Array.prototype.filter on developer.mozilla.org for good examples of how array filters work.

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:

  • .addProxyListener(callback): Add a function to the sequence of proxy listeners.
  • .removeProxyListener(callback): Remove the function from the sequence of proxy listeners.

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

  • get stopped(): True if iteration to the remaining proxy listeners is canceled.
  • get proxy(): The proxy object (or value) currently scheduled to be returned to the user.
  • set proxy(val): Override the proxy object to schedule another value to be returned in its place.
  • get target(): The original value being hidden in a proxy.
  • get handler(): The proxy handler which the proxy is based on.
  • set handler(val): Override the proxy handler.
  • rebuildProxy(): A method to recreate the proxy from the original value and the current proxy handler.
  • logger: A log4javascript-compatible logging object from the membrane's construction options, or null.
  • For the .apply and .construct traps, there are additional properties:
    • trapName: Either "apply" or "construct", depending on the trap invoked.
    • callable: The target function being called.
    • isThis: True for the "this" argument in the "apply" trap. False otherwise.
    • argIndex: The argument index number, if isThis is false.
  • stopIteration(): Set stopped to true, so that no more proxy listeners are executed.
  • throwException(exception): Explicitly throw an exception, so that the proxy is NOT returned but the exception propagates from the membrane.

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

  • The exception will be caught, and if the membrane has a logger, the logger will log the exception.
  • The next proxy listener in the sequence will then execute as if the exception had not been thrown.

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

  • The Membrane's prototype methods provide API for getting unique ObjectGraphHandler instances:
    • .getHandlerByName(graphName, mustCreate = false)
    • .ownsHandler(handler)
  • The Membrane's prototype also manages access to ProxyMapping instances, which as we stated above match proxies to original values in an one-to-one relationship.
    • .hasProxyForValue(field, value)
    • .buildMapping(field, value, options)
    • .convertArgumentToProxy(originHandler, targetHandler, arg, options)
    • .getMembraneValue(field, value) (private method)
    • .getMembraneProxy(field, value) (private method)
    • .wrapArgumentByHandler(handler, arg, options) (private method)
    • .wrapArgumentByProxyMapping(mapping, arg, options) (private method)
    • .wrapDescriptor(originField, targetField, desc) (private method)
  • The Membrane also maintains a WeakMap(object or proxy -> ProxyMapping) member.
  • Each ObjectGraphHandler is a handler for a Proxy implementing all the traps.
    • For simple operations that don't need a Membrane, such as .isExtensible(), the handler forwards the request directly to Reflect, which also implements the Proxy handler traps.
    • (An exception to this rule is the .has() trap, which uses this.getOwnPropertyDescriptor and this.getPrototypeOf to walk the prototype chain, without the .has trap itself crossing the object graph's borders.)
    • When getting a property descriptor from a proxy by the property name, there are several steps:
      1. Look up the ProxyMapping object matching the proxy's target in the membrane's WeakMap.
      2. Look up the original "this" object from the ProxyMapping, and call it originalThis. (This is the object the proxy corresponds to.)
      3. Set rv = Reflect.getOwnPropertyDescriptor(originalThis, propertyName);
      4. Wrap the non-primitive properties of rv as objects in the membrane, just like originalThis is wrapped in the membrane.
      • This includes the .get(), .set() methods of accessor descriptors, and the .value of a data descriptor.
      1. Return rv.
    • When getting the prototype of a proxy,
      1. Look up the ProxyMapping object matching the proxy's target in the membrane's WeakMap.
      2. If the retrieved ProxyMapping object doesn't have a valid "protoMapping" property,
      3. Look up the original "this" object from the ProxyMapping, and call it originalThis.
      4. Set proto = Reflect.getPrototypeOf(originalThis).
      5. Wrap proto, and a new Proxy for proto in the desired object graph, in the membrane via a second ProxyMapping.
      6. Set the second ProxyMapping object as the "protoMapping" property of the first ProxyMapping object.
      7. Return the proxy belonging to both the object graph and the "protoMapping" object.
    • The .get() trap follows the ECMAScript 7th Edition specification for .get(), calling its .getOwnPropertyDescriptor() and .getPrototypeOf() traps respectively. It then wraps whatever return value it gets from those methods.
    • When I say 'wrap a value', what I mean is:
      1. If the value is a primitive, just return the value as-is.
      2. If there isn't a ProxyMapping in the owning Membrane's WeakMap for the value,
      3. Create a ProxyMapping and set it in the membrane's WeakMap.
      4. Let origin be the ObjectGraphHandler that the value came from, and target be this.
      5. Set the value as a property of the ProxyMapping with the name of the origin object graph.
      6. Let parts = Proxy.revocable(value, this).
      7. Set parts as a property of the ProxyMapping with this object graph's name.
      8. Get the ProxyMapping for the value from the owning Membrane's WeakMap.
      9. Get the property of the ProxyMapping with this object's graph name.
      10. Return the property (which should be a Proxy).
    • There is an algorithm for "counter-wrap a value", which we use for passing in arguments to a wrapped function, defining a property, or setting a prototype. The algorithm is similar to the "wrap a value" algorithm, except that the "origin" ObjectGraphHandler and the "target" ObjectGraphHandler are reversed.
    • For executing a function proxy via .apply(),
      1. Counter-wrap the "this" value and all arguments.
      2. Look up the original function.
      3. Let rv = Reflect.apply(original function, counterWrapped this, counterWrapped arguments);
      4. Wrap rv and return the wrapped value.
    • For executing a function proxy as a constructor via .construct(target, argumentList, newTarget),
      1. Get the ObjectGraphHandler representing the original function,
      2. Counter-wrap all the members of argumentList.
      3. Let rv = Reflect.construct(target, wrappedArgumentList, newTarget).
      4. Wrap rv.
      5. Get the prototype property of target (which is our constructor function).
      6. Wrap the prototype property.
      7. this.setPrototypeOf(rv, wrappedProto).
      8. Return rv.

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.

Readme

Keywords

none

Package Sidebar

Install

npm i es-membrane

Weekly Downloads

6

Version

0.9.0

License

ISC

Unpacked Size

4.58 MB

Total Files

498

Last publish

Collaborators

  • ajvincent