kolodny/jsan

Support non-JSON values

Closed this issue · 14 comments

(see zalmoxisus/redux-devtools-extension#60)

It would be nice to have a possibility to serialize values like function or undefined.

Probably, there should be a parameter in stringify() method to enable it.

I'm not sure how stringifing and parsing functions can work, if there are any clousred variables it will lost binding to a "reparsed" function, here's a basic example:

function createCounter() {
  var count = 0;
  return function() { return count++; }
}

var counter = createCounter();
counter();
counter();

var stringified = jsan.stringify(counter); // {$ref: 'function() { return count++ }'}
var parsed = jsan.parse(stringified);
parsed(); // Uncaught ReferenceError: count is not defined

While this can work and makes sense for pure functions eg const inc = val => val + 1, there's no way of knowing this so I'm unsure if this should be added to the library.

A possible solution would be to use the replacer and reviver arguments in jsan.stringify and jsan.parse:

var fn = function(a) { return a + 1 }

var str = jsan.stringify({a: 1, b: { foo: fn }},  function(key, value) {
  if (typeof value === 'function') return {$ref: {$function: value.toString()}};
  else return value
})


var parsed = jsan.parse(str, function(key, value) {
  console.log(key, value);
  if (value && value.$ref && value.$ref.$function) {
    return Function('return ' + value.$ref.$function)();
  }

  return value
})

console.log(str); // {"a":1,"b":{"foo":{"$ref":{"$function":"function (a) { return a + 1 }"}}}}

console.log(parsed.b.foo(5)) // 6

Another solution that comes to mind, is that if the stringifing and parsing needs to happen on the same place, then you can keep a reference to the original function and drop a placeholder for that function, and on the parse step, if the placeholder is found, replace it with the appropriate value:

var str = jsan.stringify({a: {b:  jQuery }}); // {"a": {"b": {"ref": "function1"}}}
var parse = jsan.parse(str) // {a: {b: jQuery}}

I'm not sure what the use case is though. @zalmoxisus thoughts?

Agree, we don't need to stringify the function's body, just setting an empty function after parsing should be ok.

So, now if we have state as

{
  type: 'some state',
  whatTodo: () => { console.log('What to do?'); } ,
  error: undefined
}

After

var str = jsan.stringify(state);
var parse = jsan.parse(str)

we get parse as

{
  type: 'some state'
}

The problem is that we lose the state tree, and one would think that those keys are not in the store. So, we need just to keep keys like:

{
  type: 'some state',
  whatTodo: function() {},
  error: undefined
}

I see. If it's just a matter of cosmetics and the function won't be executed then I can offer a solution similar to the solution I gave above without needing an update in jsan itself. It would also work with undefined, null and regexes

Would that work?

@kolodny sure, we don't need function body actually, so it would work just fine (but it would be useful to preserve function name and its signature, probably)

That would work great. I guess we don't get function's name and signature in the log monitor, but for other monitors would be useful.

Ok, you can use these functions. I've included the tests too. Let me know if this works

var jsan = require('jsan');

function getRegexFlags(regex) {
  var flags = '';
  if (regex.ignoreCase) flags += 'i';
  if (regex.global) flags += 'g';
  if (regex.multiline) flags += 'm';
  return flags;
}

function stringifyFunction(key, fn) {
  var str = fn.toString();
  var sig;
  var match;
  if (match = str.match(/^(\w+)\s*=>/)) {
    sig = '(' + match[1];
  }
  if (match = str.match(/^(?:function\b)?\s*([^)]*)\)/)) {
    sig = match[1]
  }
  return 'function ' + (sig[0] === '(' ? key + sig : sig) + ') { /* ... */ }'
}

function replacer(key, value) {
  if (typeof value === 'function') {
    return {$function: stringifyFunction(key, value)};
  } else if (value instanceof RegExp) {
    return {$regex: getRegexFlags(value) + ',' + value.source};
  } else if (value === undefined) {
    return {$undefined: true};
  }
  return value;
}

function reviver(undefines) {
  return function(key, value) {
    if (value) {
      if (value.$function) {
        return Function('return ' + value.$function)();
      } else if (value.$regex) {
        var parts = value.$regex.split(',');
        var flags = parts[0];
        var source = parts.slice(1).join(',');
        return RegExp(source, flags);
      }
      Object.keys(value).forEach(key => {
        if (value[key] && value[key].$undefined) {
          undefines.push({ key, obj: value });
        }
      })
    }
    return value;
  }
}

function myStringify(obj) {
  return jsan.stringify(obj, replacer);
}

exports.myStringify = function myStringify(obj) {
  return jsan.stringify(obj, replacer);
}

exports.myParse = function myParse(str) {
  var undefines = [];
  var obj = jsan.parse(str, reviver(undefines));
  undefines.forEach(function(item) { item.obj[item.key] = undefined });
  return obj;
}

Tests

var assert = require('assert');

describe('myStringify and myParse', function() {
  ['basic', 'intense'].forEach(function(name, shouldTestSelf) {
    it('works on "' + name + '" mode', function() {

      var state = {
        type: 'some state',
        checker: /name=([^&]*)/g,
        whatTodo: (a, b, c) => { console.log('What to do?'); } ,
        arr: foo => foo,
        f2: function f4(b,c,d) {},
        f3(e,f,g) {},
        error: undefined
      };
      if (shouldTestSelf) {
        state.self = state;
      }

      var str = myStringify(state);
      var newState = myParse(str);

      if (shouldTestSelf) {
        assert(newState.self === newState);
      } else {
        assert(!newState.self);
      }

      assert('error' in newState && newState.error === undefined);
      assert(newState.checker instanceof RegExp);
      assert(typeof newState.whatTodo === 'function');
      assert(newState.whatTodo.toString() === function whatTodo(a, b, c) { /* ... */ }.toString());
      assert(typeof newState.arr === 'function');
      assert(newState.arr.toString() === function arr(foo) { /* ... */ }.toString());
      assert(typeof newState.f2 === 'function');
      assert(newState.f2.toString() === function f4(b,c,d) { /* ... */ }.toString());
      assert(typeof newState.f3 === 'function');
      assert(newState.f3.toString() === function f3(e,f,g) { /* ... */ }.toString());
    });
  });

});

@kolodny, thanks a lot! Tested, that's exactly wha we need.

Do you plan to add these helpers in jsan (it can be in separate file to keep the original lib simple)? If not I would be happy to create another package, let's say jsan-extended :) As I need this also for remote-redux-devtools and remotedev.

I plan on releasing a new version that serializes to something like this:

var state = {
  type: 'some state',
  checker: /name=([^&]*)/g,
  whatTodo: (a, b, c) => { console.log('What to do?'); } ,
  arr: foo => foo,
  f2: function f4(b,c,d) {},
  f3(e,f,g) {},
  error: undefined,
  $jsan: 'foobar'
};
state.self = state;

var str = jsan.stringify(state);

str will be equal like this

{
  "type": "some state",
  "checker": {
    "$jsan": "rg,name=([^&]*)"
  },
  "whatTodo": {
    "$jsan": "ffunction whatTodo(a, b, c) { /* ... */ }"
  },
  "arr": {
    "$jsan": "ffunction arr(foo) { /* ... */ }"
  },
  "f2": {
    "$jsan": "ffunction f4(b,c,d) { /* ... */ }"
  },
  "f3": {
    "$jsan": "ffunction f3(e,f,g) { /* ... */ }"
  },
  "error": {
    "$jsan": "u"
  },
  "self": {
    "$ref": "$"
  },
  "\\$jsan": "foo"
}

or something like that

I've just pushed a next branch. Try it out and let me know if it works. I think I covered everything in the test cases, I just want to double check and clean up some docs.

You can install it like so:

npm install kolodny/jsan#next

The usage for your use case should look like this

- message.payload = stringify(message.payload);
+ message.payload = stringify(message.payload, null, null, true);

other than that everything else can stay the same

Once you give the 👍 I'll start the publishing process to 3.0.0

Thanks a lot! It works great now.

Yet you stringify function's body, wouldn't it have any performance issues for large functions? Especially I am worried about Remote Redux Devtools where we pass this data through websockets.

Ok, makes sense, I've changed it to turn into something like function fn(a,b,c) { /* ... */ } and I've allowed passing in a custom function toString() method so you can pass in {['function']: function(fn) { return fn.toString() }} to still get the full body https://github.com/kolodny/jsan/blob/next/test/end-to-end.js#L70

Awesome! Checked, that's exactly what we wanted to achieve 👍

Ok, I published 3.0.0, you're good to go