Monkey Patching in Node.js

Disclaimer: Don't do this. Really. This post is just for the fun of it.

Monkey patching (a.k.a. "Duck Punching") in Node.js is somewhat of a controversial topic. Conventional wisdom says to steer clear of this activity, but I think sometimes it is inevitable. The best way to avoid monkey patching is to actually just send a pull request. But who has time for that (trollface)?

Okay, so let's first assume a PR is out of the question for some reason or another. Second, let's also assume that you've nailed down your dependency that you're patching. How should you go about implementing the patch? I like the idea of a patch being its own module, if possible. That gives us a nice and contained place for actually applying the patch.

Next, it is best to bake some assertions about the state of the code into your patch. If you blindly apply the patch, you could actually run into some trouble that you could easily avoid. One example is applying the patch twice. Since the patch is limited to the scope of this module, we get to bundle the assumptions with the actual patch.

It may seem kind of obvious, but these are all things that are easy to overlook when you want to rely on this code.

Example

I'll walk through a quick example of how one might build a monkey patch. The Bunyan package has a RingBuffer that you can use as a log destination. It's a simple structure that keeps the last several entries in memory. While it inherits from events.EventEmitter, it only sends a close event. I had an idea for integrating it into an app I was working on, but would require a way for the RingBuffer to notify you when a record has been written, so I decided to try and patch the behavior.

The Patch

The actual patch looks like this:

var bunyan = require('bunyan');
bunyan.RingBuffer.prototype.inner_write = bunyan.RingBuffer.prototype.write;
bunyan.RingBuffer.prototype.write = function monkeyPatchedRingBufferWrite(record) {
  if (this.inner_write(record)) {
    this.emit('data', record);
  }
};

Now, instead of just making the write, if a write was made we can emit a data event with the record that was written. Not much to it!

Runtime Assertions

It's worth pointing out that applying this patch twice is going to start causing some truly strange behavior. We can write a quick test to assert whether or not the desired behavior is present:

function desired_behavior_exists() {
  var ring = new bunyan.RingBuffer()
  var called = false;

  ring.on('data', function () {
    called = true;
  });

  ring.write({message:'hi'});

  return called;
}

This code should allow us to test for this behavior at any point; pre or post patch. Also worth poitning out is that EventEmitter.emit() is a blocking call. It doesn't queue the event listeners in the event loop.

Checking for the desired behavior is a good start, but surely there are a few other things that could trip us up, and cause unpredictable consequences. What if Bunyan reworked their RingBuffer structure, and dropped EventEmitter from its inheritance chain? Well, that would definitely cause problems. Here is how we can test to make sure that the RingBuffer inherits from EventEmitter:

function init_check() {
  var ring = new bunyan.RingBuffer();
  var superNameMatches = null;
  var superName = null;

  if (!bunyan.RingBuffer.super_) {
    console.warn('bunyan.RingBuffer does not inherit from another function; assumptions faulty. Skipping patch.');
    return false;
  }

  if (!(ring instanceof EventEmitter)) {
    superNameMatches = bunyan.RingBuffer.super_.toString()
      .replace(/function /, '')
      .match(/^\w+/);

    if (superNameMatches.length > 0) {
      superName = superNameMatches[0];
    }

    console.warn('bunyan.RingBuffer prototype mismatch. Expected EventEmitter, is %s. Skipping patch.', superName);

    return false;
  }

  return true;
}

The above code not only checks to see that RingBuffer inherits from EventEmitter, but it also checks to make sure that it inherits from anything at all!

Conclusions

Node.js gives you no shortage of power in times of need. Just make sure you're taking a moment to check your assumptions and hedge your bets! Then, as soon as you're sure you know what you're doing, don't do it anyway.

The Complete Module

Here is the complete version of this monkey patching module:

var bunyan = require('bunyan');
var EventEmitter = require('events').EventEmitter;

function init_check() {
  var ring = new bunyan.RingBuffer();
  var superNameMatches = null;
  var superName = null;

  if (!bunyan.RingBuffer.super_) {
    console.warn('bunyan.RingBuffer does not inherit from another function; assumptions faulty. Skipping patch.');
    return false;
  }

  if (!(ring instanceof EventEmitter)) {
    superNameMatches = bunyan.RingBuffer.super_.toString()
      .replace(/function /, '')
      .match(/^\w+/);

    if (superNameMatches.length > 0) {
      superName = superNameMatches[0];
    }

    console.warn('bunyan.RingBuffer prototype mismatch. Expected EventEmitter, is %s. Skipping patch.', superName);

    return false;
  }

  return true;
}

function desired_behavior_exists() {
  var ring = new bunyan.RingBuffer()
  var called = false;

  ring.on('data', function () {
    called = true;
  });

  ring.write({message:'hi'});

  return called;
}

function apply() {
  if (!init_check()) {
    return;
  }

  if (desired_behavior_exists()) {
    console.info('bunyan.RingBuffer.write passed pre-test. Patch is unnecessary.');
    return;
  }

  bunyan.RingBuffer.prototype.inner_write = bunyan.RingBuffer.prototype.write;
  bunyan.RingBuffer.prototype.write = function monkeyPatchedRingBufferWrite(record) {
    if (this.inner_write(record)) {
      this.emit('data', record);
    }
  };

  if (desired_behavior_exists()) {
    console.log('bunyan.RingBuffer.write patch successfully applied.');
  } else {
    console.error('bunyan.RingBuffer.write patch failed post-test. Patch not applied.');
  }
}

module.exports.apply = apply;
Show Comments