@endo/marshal
"Marshalling" refers to the conversion of structured data (a tree or graph of
objects) into a string, and back again.
The marshal
module helps with conversion of "capability-bearing data", in
which some portion of the structured input represents "pass-by-proxy" or
"pass-by-presence" objects that should be serialized into values referencing
special "slot identifiers". The toCapData()
function returns a "CapData"
structure: an object with a body
containing a serialization of the input data,
and a slots
array holding the slot identifiers. fromCapData()
takes this
CapData structure and returns the object graph. There is no generic way to
convert between pass-by-presence objects and slot identifiers, so the marshaller
is parameterized with a pair of functions to create the slot identifiers and turn
them back into proxies/presences.
marshal
uses JSON to serialize the object graph, but knows how to serialize
values that cannot be expressed directly in JSON, such as bigints, NaN
, and
undefined
.
Usage
This module exports a makeMarshal()
function, which can be called with two
optional callbacks (convertValToSlot
and convertSlotToVal
), and returns
an object with toCapData
and fromCapData
properties. Each callback defaults
to the identity function.
import '@endo/init';
import { makeMarshal } from '@endo/marshal';
const m = makeMarshal();
const o = harden({a: 1});
const s = m.toCapData(o);
console.log(s);
const o2 = m.fromCapData(s);
console.log(o2);
console.log(o1 === o2);
Frozen Objects Only
The entire object graph must be "hardened" (recursively frozen), such as done
by the harden
function installed when importing @endo/init
. toCapData
will
refuse to marshal any object graph that contains a non-frozen object.
Beyond JSON
marshal
uses special values to represent both Presences and data which cannot
be expressed directly in JSON. These special values are usually strings with
reserved prefixes in the preferred "smallcaps" encoding, but in the original
encoding were objects with a property named @qclass
. For example:
import '@endo/init';
import { makeMarshal } from '@endo/marshal';
const m1 = makeMarshal(undefined, undefined, { serializeBodyFormat: 'smallcaps' });
console.log(m1.toCapData(NaN));
const m2 = makeMarshal();
console.log(m2.toCapData(NaN));
Pass-by-Presence vs Pass-by-Copy
marshal
relies upon @endo/pass-style
to distinguish between objects that are
pass-by-presence and those that are pass-by-copy.
To qualify as pass-by-presence, all properties of an object (and of all objects
in its inheritance hierarchy) must be methods, not data. Pass-by-presence objects
are usually treated as having identity (assuming the convertValToSlot
and
convertSlotToVal
callbacks behave well), so passing the same object through
multiple calls will result in multiple references to the same output object.
To qualify as pass-by-copy, all properties of an object must be string-named and
enumerable and not accessors and not methods: their values can be primitives such
as bigints, booleans, null
, numbers, and strings, and they can be arrays and
pass-by-copy objects, but they cannot be functions. In addition, the object must
inherit directly from Object.prototype
. Pass-by-copy objects are not treated as
having identity: fromCapData
does not produce the same output object for
multiple appearances of the same pass-by-copy serialization.
Mixed objects having both methods and data properties are rejected.
Empty objects (which vacuously satisfy requirements for both pass-by-presence and
pass-by-copy) are treated as pass-by-copy, although it is also possible to use
Far
(from @endo/far
) for creating empty marker objects which can be
compared for identity and are especially useful as WeakMap keys in the "rights
amplification" pattern.
convertValToSlot
/ convertSlotToVal
When m.toCapData()
encounters a pass-by-presence object, it will call the
convertValToSlot
callback with the value to be serialized. The return value
will be used as the slot identifier to be placed into the slots array, and the
serialized body
, in place of the object, will contain a special value
referencing that slot identifier by its index in the slots array. For example:
import '@endo/init';
import { makeMarshal } from '@endo/marshal';
const slotAssignments = new Map();
const convertValToSlot = obj => {
let slot = slotAssignments.get(obj);
if (slot === undefined) {
slot = `id1:${(slotAssignments.size + 10).toString(36)}`;
slotAssignments.set(obj, slot);
}
return slot;
};
const p = harden(Promise.resolve());
const m1 = makeMarshal(convertValToSlot, undefined, { serializeBodyFormat: 'smallcaps' });
m1.toCapData(p);
const m2 = makeMarshal(convertValToSlot);
m2.toCapData(p);
Each time m.fromCapData()
encounters a slot reference, it calls
convertSlotToVal
with the value from the slots array. convertSlotToVal
should create and return a proxy (or other representative) of the
pass-by-presence object.
As a direct alternative to JSON
This marshal package also exports stringify
and parse
functions that are
built on the marshal encoding of passable data. They can serve as direct
substitutes for JSON.stringify
and JSON.parse
, respectively, with the
following differences:
- Compared to JSON, marshal's
stringify
is both more tolerant and less tolerant
of what data it accepts. It is more tolerant in that it will encode NaN
,
Infinity
, -Infinity
, bigints, and undefined
. It is less tolerant in that
it accepts only pass-by-copy data according to the semantics of our distributed
object model, as enforced by marshal---the Passable
type exported by the
marshal package. For example, all objects-as-records must be frozen, inherit
from Object.prototype
, and have only enumerable string-named data properties.
JSON.stringify
handles unserializable data by skipping it, but marshal's
stringify
rejects it by throwing an error. - The JSON functions have parameters for customizing serialization and
deserialization, for example with a replacer or reviver. The marshal-based
alternatives do not.
The full marshal package will serialize Passable
objects containing
presences and promises, because it serializes to a CapData
structure
containing both a body
string and a slots
array. Marshal's stringify
function serializes only to a string, and so will not
accept any remotables or promises. If any are found in the input, this
stringify
will throw an error.
Any encoding into JSON of data that cannot be represented directly, such as
NaN
, relies on some kind of escape for the decoding side to detect and use.
For stringify
and parse
, this is signaled by an object with a property named
@qclass
per the original encoding described above.