Skip to main content

WIRE_JS

Constant WIRE_JS 

Source
pub const WIRE_JS: &str = "// The wire format: how a ZD value survives the trip to the store and back.\n//\n// # The bug this file exists to fix\n//\n// `JSON.stringify` cannot represent a JavaScript `Map`. It does not throw,\n// it does not warn, it returns `{}`:\n//\n//     JSON.stringify(new Map([[\'ada\', 1]]))   // \u{2192} \"{}\"\n//\n// A `Map` is what \u{a7}5.4\'s `Map of K to V` compiles to \u{2014} an object would\n// coerce every key to a string, which is exactly why it is a `Map` \u{2014} so\n// every `durable Map` wrote an empty object and read nothing back. It\n// failed silently, which for a persistence bug is the worst way to fail.\n//\n// # Why a marker and not an array\n//\n// `Map` and `record` cannot share an encoding: a record is a plain object\n// and a map has to be distinguishable from one, or `decode` cannot know\n// which to rebuild. So a map is tagged:\n//\n//     new Map([[\'ada\', 1]])   \u{21c4}   { \"$map\": [[\"ada\", 1]] }\n//\n// `$` cannot appear in a ZD identifier \u{2014} the lexer\'s rule is\n// `[\\p{XID_Start}_][\\p{XID_Continue}]*`, and `$` is in neither class \u{2014} so\n// no record field can ever be named `$map` and the marker is unambiguous\n// by construction rather than by convention.\n//\n// # What the four shapes look like on the wire\n//\n//     Whole, Decimal   number          Text     string\n//     Truth            boolean         empty    []  or  {\"$map\":[]}\n//     List of T        array           record   object, fields by name\n//     Map of K to V    {\"$map\":[[k,v]]}         choice   {tag, fields}\n//\n// A choice is `{ tag, fields }` (see `variant` in `dom.js`) and a record is\n// a plain object, so both ride as ordinary JSON objects and recurse.\n//\n// # One definition, three users\n//\n// The browser encodes a request body, the adapter decodes it and encodes\n// what it stores, and the live-sync stream carries the encoded form\n// straight through. Three places, one file \u{2014} a second copy of these rules\n// anywhere is how the two halves come to disagree about what `{}` means.\n//\n// # Why `encode` consults `toJSON`, and why it is not a second marker\n//\n// `encode` runs *before* `JSON.stringify` and hands it a value that has\n// already been walked. That is what makes the `$map` marker possible, and\n// it also means `JSON.stringify` never sees the original object, so every\n// `toJSON` in the program was silently defeated, and any type that grew one\n// later would have been defeated the same way.\n//\n// It cost this once already. `append` compiles to a chain of links rather\n// than to an array, because appending has to be O(1) or a builder is\n// quadratic, and the class carries a `toJSON` that flattens the chain for\n// exactly this trip. `encode` walked past it: a link is not a `Map` and\n// `Array.isArray` is false for one, so it fell through to the record branch\n// and a durable `[1]` was stored as `{\"base\":[],\"item\":1,\"flat\":null}`\n// (#204).\n//\n// The narrow fix would have been a third branch that recognises the link\n// class. It was rejected: the mistake is not that this file does not know\n// about `append`, it is that walking structurally overrides what a value\n// says about its own JSON form, and a third branch leaves that true for the\n// fourth type. So `toJSON` is consulted generally and first, which is the\n// rule `JSON.stringify` itself follows, and a type that has an opinion\n// about its JSON form now gets it honoured at both layers instead of one.\n//\n// This does not weaken the `$map` marker\'s argument. A `Map` has no\n// `toJSON`, which is the whole reason this file exists, so nothing about\n// how a map rides has changed.\n\n/** A ZD value as JSON-representable data. */\nexport function encode(value) {\n  if (value !== null && typeof value === \'object\' && typeof value.toJSON === \'function\') {\n    const declared = value.toJSON();\n    // A `toJSON` that hands back its own receiver has declared nothing, and\n    // recursing on it would not terminate. Walking it structurally is what\n    // this function did for every value before, so that is what it falls\n    // back to rather than throwing on a value it can still encode.\n    if (declared !== value) return encode(declared);\n  }\n  if (value instanceof Map) {\n    const entries = [];\n    // `forEach` rather than `for\u{2026}of` \u{2014} see the engine note in `signal.js`.\n    value.forEach((item, key) => entries.push([encode(key), encode(item)]));\n    return { $map: entries };\n  }\n  if (Array.isArray(value)) {\n    return value.map(encode);\n  }\n  if (value !== null && typeof value === \'object\') {\n    const out = {};\n    for (const field of Object.keys(value)) {\n      Object.defineProperty(out, field, {\n        value: encode(value[field]),\n        enumerable: true,\n        configurable: true,\n        writable: true,\n      });\n    }\n    return out;\n  }\n  // `undefined` is not JSON. A durable key that holds nothing reads back as\n  // absent, and the endpoint applies the declared `starting` value \u{2014} so\n  // sending `null` here is what makes those two agree.\n  return value === undefined ? null : value;\n}\n\n/** The inverse. */\nexport function decode(value) {\n  if (Array.isArray(value)) {\n    return value.map(decode);\n  }\n  if (value !== null && typeof value === \'object\') {\n    if (Object.prototype.hasOwnProperty.call(value, \'$map\')) {\n      // Strict, because `decode` does not only run on data this runtime\n      // encoded. `rpc.js` decodes whatever an endpoint answers with and\n      // `store.js` decodes whatever a live-sync frame carries, and neither\n      // is under the program\'s control. The marker\'s unambiguity is an\n      // argument about ZD *identifiers*, and it says nothing about a\n      // payload that is merely shaped like one.\n      //\n      // Every one of these used to be a silent conversion: a non-array\n      // `$map` became an empty map, sibling fields vanished, and a\n      // malformed pair was skipped. Silent is the one thing a persistence\n      // format must not be \u{2014} that is the whole reason this file exists.\n      const keys = Object.keys(value);\n      if (keys.length !== 1) {\n        throw new Error(\n          `A map on the wire carries only \"$map\"; this one also carried ${JSON.stringify(\n            keys.filter((key) => key !== \'$map\')\n          )}.`\n        );\n      }\n      const entries = value.$map;\n      if (!Array.isArray(entries)) {\n        throw new Error(\'A map on the wire is an array of [key, value] pairs.\');\n      }\n      const rebuilt = new Map();\n      for (const entry of entries) {\n        if (!Array.isArray(entry) || entry.length !== 2) {\n          throw new Error(\'A map entry on the wire is a [key, value] pair.\');\n        }\n        rebuilt.set(decode(entry[0]), decode(entry[1]));\n      }\n      return rebuilt;\n    }\n    const out = {};\n    for (const field of Object.keys(value)) {\n      // Assignment to `__proto__` invokes Object.prototype\'s legacy\n      // setter instead of creating the record field. ZD identifiers may\n      // legally spell `__proto__`, so define every field as an own data\n      // property and keep the decoded record\'s prototype unchanged.\n      Object.defineProperty(out, field, {\n        value: decode(value[field]),\n        enumerable: true,\n        configurable: true,\n        writable: true,\n      });\n    }\n    return out;\n  }\n  return value;\n}\n\n// $dev\n/**\n * Assert `encode` left nothing `JSON.stringify` writes as `{}`.\n *\n * **This checks #204\'s family rather than its instance.** The bug at the\n * top of this file was a `Map` reaching `JSON.stringify`, which does not\n * throw and does not warn: it returns `{}`, so a `durable Map` wrote an\n * empty object and read nothing back. `encode` fixes that for `Map`, and\n * since the `toJSON` change for a type that declares its own JSON form.\n * What neither fixes is the *next* type with the same property, and no\n * static pass anywhere would see it \u{2014} the value is a JavaScript object\n * either way.\n *\n * So the invariant is checked instead of the case: after `encode`, every\n * object left is an array or a plain object and every leaf is a JSON\n * scalar. A `Map`, a `Set`, a `Date` or any class instance that does not\n * declare a `toJSON` fails here, naming the path to itself, instead of\n * silently becoming `{}` in somebody\'s store.\n *\n * Development only. A release build runs `JSON.stringify` against the same\n * encoded value with no check in front of it, which is what it has always\n * done.\n *\n * The walk is a worklist rather than recursion, and that is not a style\n * choice: `encode` is already recursive, and a second recursion over the\n * same value doubles the stack a nested value needs. `wire_fuzz.rs`\n * generates values deep enough that it does not fit, so an assertion\n * written the obvious way would fail on values the format carries.\n */\nexport function assertEncoded(root, path) {\n  const pending = [[root, path === \'\' ? \'the value\' : path]];\n  while (pending.length > 0) {\n    const [value, at] = pending.pop();\n    if (value === null) continue;\n    const type = typeof value;\n    if (type === \'boolean\' || type === \'string\' || type === \'number\') continue;\n    // `NaN` and the infinities are deliberately *not* refused here. They\n    // are numbers JSON writes as `null`, so they do not survive the trip \u{2014}\n    // but that is the format\'s own recorded behaviour (#144), asserted by\n    // `wire_fuzz.rs`, and an assertion that refused them would be this file\n    // changing the format rather than checking it.\n    if (type !== \'object\') {\n      throw new Error(`${at} is a ${type}, which JSON cannot represent.`);\n    }\n    if (Array.isArray(value)) {\n      for (let i = 0; i < value.length; i += 1) pending.push([value[i], `${at}[${i}]`]);\n      continue;\n    }\n    const prototype = Object.getPrototypeOf(value);\n    if (prototype !== Object.prototype && prototype !== null) {\n      const name = (value.constructor && value.constructor.name) || \'class instance\';\n      throw new Error(\n        `${at} is a ${name} after encoding, and JSON.stringify writes that ` +\n          `as {} without saying so. Give it a toJSON, or encode it in encode().`\n      );\n    }\n    for (const field of Object.keys(value)) pending.push([value[field], `${at}.${field}`]);\n  }\n}\n// $end\n\n/** A ZD value as the JSON text that crosses the wire. */\nexport function stringify(value) {\n  const encoded = encode(value);\n  // $dev\n  assertEncoded(encoded, \'\');\n  // $end\n  const text = JSON.stringify(encoded);\n  return text === undefined ? \'null\' : text;\n}\n\n/** JSON text back into a ZD value. */\nexport function parse(text) {\n  return decode(JSON.parse(text));\n}\n";
Expand description

The wire format: how a ZD value survives JSON.

Its own module because three separate things encode and decode with it — the browser, the platform adapter, and the live-sync stream — and a second copy of the rules is how they come to disagree.