import * as utf8 from "@protobufjs/utf8"; import { ArrayMessage, CollabIDMessage, DefaultSerializerMessage, IDefaultSerializerMessage, ObjectMessage, PairSerializerMessage, } from "../../generated/proto_compiled"; import { Collab, CollabID } from "../core"; import { nonNull } from "./assertions"; import { Optional } from "./optional"; import { SafeWeakRef } from "./safe_weak_ref"; /** * A serializer for values of type `T`. * * Collabs with a generic type `T`, like [[CVar]]`` or * [[CValueSet]]``, need a Serializer\ so that they can send * values of type `T` from one replica to another. * * By default, * all built-in Collabs use [[DefaultSerializer]], which permits * JSON values and some others. To use more general `T` * or to optimize the encoding, you must supply your own * Serializer\, typically in the Collab constructor's * `options` parameter. * * Serializers provided with the library (some only in package @collabs/core) * include [[DefaultSerializer]], * [CollabIDSerializer](../../core/classes/CollabIDSerializer.html), * [StringSerializer](../../core/classes/StringSerializer.html), * [Uint8ArraySerializer](../../core/classes/Uint8ArraySerializer.html), * [ArraySerializer](../../core/classes/ArraySerializer.html), * [PairSerializer](../../core/classes/PairSerializer.html), * and [ConstSerializer](../../core/classes/ConstSerializer.html). */ export interface Serializer { /** * Returns a Uint8Array that is the serialized form of value. * * To recover the original value, use [[deserialize]]. */ serialize(value: T): Uint8Array; /** * Inverse of [[serialize]]. * * Typically, this returns a deep clone of the original value, not * the same literal reference. Indeed, it is impossible to return * the original reference on a different replica. */ deserialize(message: Uint8Array): T; } // In this file, we generally cache instances in case each // element of a collection constructs a derived serializer // from a fixed given one. /** * Default [[Serializer]]. * * Supported types are a superset of JSON: * - Primitive types (string, number, boolean, undefined, null) * - Arrays and plain (non-class) objects, serialized recursively * - [[CollabID]]s (covered by the previous case since they are plain objects) * - Uint8Array * - [[Optional]], with T serialized recursively. * * All other types cause an error during [[serialize]]. * * Construct using [[getInstance]]. */ export class DefaultSerializer implements Serializer { private constructor() { // Singleton. } private static instance = new this(); /** * Returns an instance of [[DefaultSerializer]]. * * Internally, all instances are the same literal object. */ static getInstance(): DefaultSerializer { return >this.instance; } serialize(value: T): Uint8Array { let message: IDefaultSerializerMessage; switch (typeof value) { case "string": message = { stringValue: value }; break; case "number": if (Number.isSafeInteger(value)) { message = { intValue: value }; } else { message = { doubleValue: value }; } break; case "boolean": message = { booleanValue: value }; break; case "undefined": message = { undefinedValue: true }; break; case "object": if (value === null) { message = { nullValue: true }; } else if (value instanceof Uint8Array) { message = { bytesValue: value, }; } else if (Array.isArray(value)) { // Technically types are bad for recursive // call to this.serialize, but it's okay because // we ignore our generic type. message = { arrayValue: ArrayMessage.create({ elements: value.map((element) => this.serialize(element)), }), }; } else if (value instanceof Optional) { message = { optionalValue: { valueIfPresent: value.isPresent ? this.serialize(value.get()) : undefined, }, }; } else { const constructor = ((value)).constructor; if (constructor === Object) { // Technically types are bad for recursive // call to this.serialize, but it's okay because // we ignore our generic type. const properties: { [key: string]: Uint8Array } = {}; for (const [key, property] of Object.entries(value)) { properties[key] = this.serialize(property); } message = { objectValue: ObjectMessage.create({ properties, }), }; } else if (value instanceof Collab) { throw new Error( "Collab serialization is not supported; serialize a CollabID instead" ); } else { throw new Error( `Unsupported class type for DefaultSerializer: ${constructor.name}; you must use a custom serializer or a plain (non-class) Object` ); } } break; default: throw new Error( `Unsupported type for DefaultSerializer: ${typeof value}; you must use a custom Serializer` ); } return DefaultSerializerMessage.encode(message).finish(); } deserialize(message: Uint8Array): T { const decoded = DefaultSerializerMessage.decode(message); let ans: unknown; switch (decoded.value) { case "stringValue": ans = decoded.stringValue; break; case "intValue": ans = int64AsNumber(decoded.intValue); break; case "doubleValue": ans = decoded.doubleValue; break; case "booleanValue": ans = decoded.booleanValue; break; case "undefinedValue": ans = undefined; break; case "nullValue": ans = null; break; case "arrayValue": ans = nonNull(nonNull(decoded.arrayValue).elements).map((serialized) => this.deserialize(serialized) ); break; case "objectValue": ans = {}; for (const [key, serialized] of Object.entries( nonNull(nonNull(decoded.objectValue).properties) )) { (>ans)[key] = this.deserialize(serialized); } break; case "bytesValue": ans = decoded.bytesValue; break; case "optionalValue": { const optionalValue = nonNull(decoded.optionalValue); if (protobufHas(optionalValue, "valueIfPresent")) { ans = Optional.of( this.deserialize(nonNull(optionalValue.valueIfPresent)) ); } else ans = Optional.empty(); break; } default: throw new Error(`Bad message format: decoded.value=${decoded.value}`); } // No way of checking if it's really type T. return ans as T; } } /** * Serializer for Uint8Array that is the identity function. * * This is a singleton class; use [[instance]] * instead of the constructor. */ export class Uint8ArraySerializer implements Serializer { private constructor() { // Use Uint8ArraySerializer.instance instead. } serialize(value: Uint8Array): Uint8Array { return value; } deserialize(message: Uint8Array): Uint8Array { return message; } static readonly instance = new Uint8ArraySerializer(); } /** * Serializer for string that uses utf-8 encoding. * * This is a singleton class; use [[instance]] * instead of the constructor. */ export class StringSerializer implements Serializer { private constructor() { // Use StringSerializer.instance instead. } serialize(value: string): Uint8Array { const ans = new Uint8Array(utf8.length(value)); utf8.write(value, ans, 0); return ans; } deserialize(message: Uint8Array): string { return utf8.read(message, 0, message.length); } static readonly instance = new StringSerializer(); } /** * Serializes T\[\] using a serializer for T. This is slightly more efficient * than [[DefaultSerializer]], and it works with arbitrary T. * * Construct using [[getInstance]]. */ export class ArraySerializer implements Serializer { private constructor(private readonly valueSerializer: Serializer) {} serialize(values: T[]): Uint8Array { const message = ArrayMessage.create({ elements: values.map((value) => this.valueSerializer.serialize(value)), }); return ArrayMessage.encode(message).finish(); } deserialize(message: Uint8Array): T[] { const decoded = ArrayMessage.decode(message); return decoded.elements.map((bytes) => this.valueSerializer.deserialize(bytes) ); } // Weak in both keys and values. private static cache = new WeakMap< Serializer, WeakRef> >(); /** * Returns an instance of [[ArraySerializer]] that uses valueSerializer * to serialize each value. * * This method may cache instances internally to save memory. */ static getInstance(valueSerializer: Serializer): ArraySerializer { const existingWeak = this.cache.get(valueSerializer); if (existingWeak !== undefined) { const existing = existingWeak.deref(); if (existing !== undefined) return >existing; } const ret = new ArraySerializer(valueSerializer); this.cache.set(valueSerializer, new SafeWeakRef(ret)); return ret; } } /** * Serializes \[T, U\] using serializers for T and U. This is slightly more efficient * than [[DefaultSerializer]], and it works with arbitrary T and U. */ export class PairSerializer implements Serializer<[T, U]> { constructor( private readonly oneSerializer: Serializer, private readonly twoSerializer: Serializer ) {} serialize(value: [T, U]): Uint8Array { const message = PairSerializerMessage.create({ one: this.oneSerializer.serialize(value[0]), two: this.twoSerializer.serialize(value[1]), }); return PairSerializerMessage.encode(message).finish(); } deserialize(message: Uint8Array): [T, U] { const decoded = PairSerializerMessage.decode(message); return [ this.oneSerializer.deserialize(decoded.one), this.twoSerializer.deserialize(decoded.two), ]; } } const emptyUint8Array = new Uint8Array(); /** * Serializes a fixed value as an empty Uint8Array. */ export class ConstSerializer implements Serializer { /** * @param value The value that [[deserialize]] will always return. */ constructor(readonly value: T) {} serialize(_value: T): Uint8Array { return emptyUint8Array; } deserialize(_message: Uint8Array): T { return this.value; } } /** * Serializes [[CollabID]]s. This is slightly more efficient * than [[DefaultSerializer]]. * * Construct using [[getInstance]]. */ export class CollabIDSerializer implements Serializer> { private constructor() { // Singleton. } private static instance = new this(); /** * Returns an instance of [[CollabIDSerializer]]. * * Internally, all instances are the same literal object. */ static getInstance(): CollabIDSerializer { return this.instance; } serialize(value: CollabID): Uint8Array { const message = CollabIDMessage.create({ collabIDPath: value.collabIDPath, }); return CollabIDMessage.encode(message).finish(); } deserialize(message: Uint8Array): CollabID { const decoded = CollabIDMessage.decode(message); return { collabIDPath: decoded.collabIDPath }; } } /** * Internal utility for working with protobuf encodings. * * Apply this function to protobuf.js uint64 and sint64 output values * to convert them to the nearest JS number (double). * For safe integers, this is exact. */ export function int64AsNumber(num: number | Long): number { // In theory you can "request" protobuf.js to not use // Longs by not depending on the Long library, in which case // you can just cast to number. But that is // flaky because a dependency might import Long. if (typeof num === "number") return num; else return num.toNumber(); } /** * Internal utility for working with protobuf encodings. * * Returns whether the optional property `prop` is present in * the deserialized protobufjs message `message`. (Accessing a not-present * property directly will return its type's default value * instead of `undefined`.) */ export function protobufHas( message: Partial>, prop: N ): boolean { // message is allowed to be Partial in case it's an I...Message type // (e.g. an inner field of a deserialized protobufjs message), // which has all optional properties. // Despite Partial, TypeScript will still complain if you misspell // prop. return Object.prototype.hasOwnProperty.call(message, prop); }