diff --git a/src/ExtensionCodec.ts b/src/ExtensionCodec.ts index edf34c69..1864bf22 100644 --- a/src/ExtensionCodec.ts +++ b/src/ExtensionCodec.ts @@ -61,25 +61,25 @@ export class ExtensionCodec implements ExtensionCodecTy } public tryToEncode(object: unknown, context: ContextType): ExtData | null { - // built-in extensions - for (let i = 0; i < this.builtInEncoders.length; i++) { - const encoder = this.builtInEncoders[i]; + // custom extensions + for (let i = 0; i < this.encoders.length; i++) { + const encoder = this.encoders[i]; if (encoder != null) { const data = encoder(object, context); if (data != null) { - const type = -1 - i; + const type = i; return new ExtData(type, data); } } } - // custom extensions - for (let i = 0; i < this.encoders.length; i++) { - const encoder = this.encoders[i]; + // built-in extensions + for (let i = 0; i < this.builtInEncoders.length; i++) { + const encoder = this.builtInEncoders[i]; if (encoder != null) { const data = encoder(object, context); if (data != null) { - const type = i; + const type = -1 - i; return new ExtData(type, data); } } diff --git a/src/JavaScriptCodec.ts b/src/JavaScriptCodec.ts new file mode 100644 index 00000000..43feb52d --- /dev/null +++ b/src/JavaScriptCodec.ts @@ -0,0 +1,163 @@ +// Implementation of "Structured Clone" algorithm in MessagPack +// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + +import { ExtensionCodec, ExtensionCodecType } from "./ExtensionCodec"; +import { encode } from "./encode"; +import { decode } from "./decode"; + +export const EXT_JAVASCRIPT = 0; + +const enum JS { + // defined in "structured clone algorithm" + // commente-outed ones are TODOs + + // Boolean = "Boolean", + // String = "String", + Date = "Date", + RegExp = "RegExp", + // Blob = "Blob", + // File = "File", + // FileList = "FileList", + ArrayBuffer = "ArrayBuffer", + Int8Array = "Int8Array", + Uint8Array = "Uint8Array", + Uint8ClampedArray = "Uint8ClampedArray", + Int16Array = "Int16Array", + Uint16Array = "Uint16Array", + Int32Array = "Int32Array", + Uint32Array = "Uint32Array", + Float32Array = "Float32Array", + Float64Array = "Float64Array", + BigInt64Array = "BigInt64Array", + BigUint64Array = "BigUint64Array", + DataView = "DataView", + // ImageBitMap = "ImageBitMap", + // ImageData = "ImageData", + Map = "Map", + Set = "Set", + + // and more + BigInt = "BigInt", +} + +export function encodeJavaScriptStructure(input: unknown): Uint8Array | null { + if (!(input instanceof Object)) { + if (typeof input === "bigint") { + return encode([JS.BigInt, input.toString()]); + } else { + return null; + } + } + const type = input.constructor.name; + + if (ArrayBuffer.isView(input)) { + if (type === JS.Uint8Array) { + return null; // fall through to the default encoder + } else if (type === JS.DataView || type === JS.Int8Array || type === JS.Uint8ClampedArray) { + // handles them as a byte buffer + const v = new Uint8Array(input.buffer, input.byteOffset, input.byteLength) + return encode([type, v]); + } else { + // handles them as a number array for portability + return encode([type, ...(input as unknown as Iterable)]); + } + } else if (type === JS.ArrayBuffer) { + const bufferView = new Uint8Array(input as ArrayBuffer); + return encode([type, bufferView]); + } else if (type === JS.Map) { + return encode([JS.Map, ...input as Map]); + } else if (type === JS.Set) { + return encode([JS.Set, ...input as Set]); + } else if (type === JS.Date) { + return encode([JS.Date, (input as Date).getTime()]); + } else if (type === JS.RegExp) { + return encode([JS.RegExp, (input as RegExp).source, (input as RegExp).flags]); + } else { + return null; + } +} + +export function decodeJavaScriptStructure(data: Uint8Array) { + const [type, ...source] = decode(data) as [JS, ...any]; + switch (type) { + case JS.BigInt: { + const [str] = source; + return BigInt(str); + } + case JS.Date: { + const [millis] = source; + return new Date(millis); + } + case JS.RegExp: { + const [pattern, flags] = source; + return new RegExp(pattern, flags); + } + case JS.ArrayBuffer: { + const [buffer] = source as [Uint8Array]; + return buffer.slice(0).buffer; + } + case JS.Int8Array: { + const [v] = source as [Uint8Array]; + return new Int8Array(v.buffer, v.byteOffset, v.byteLength); + } + case JS.Uint8Array: { + // unlikely because it is handled by the default decoder, + // but technically possible with no conflict. + const [v] = source as [Uint8Array]; + return new Uint8Array(v.buffer, v.byteOffset, v.byteLength); + } + case JS.Uint8ClampedArray: { + const [v] = source as [Uint8Array]; + return new Uint8ClampedArray(v.buffer, v.byteOffset, v.byteLength); + } + case JS.Int16Array: { + return Int16Array.from(source as ReadonlyArray); + } + case JS.Uint16Array: { + return Uint16Array.from(source as ReadonlyArray); + } + case JS.Int32Array: { + return Int32Array.from(source as ReadonlyArray); + } + case JS.Uint32Array: { + return Uint32Array.from(source as ReadonlyArray); + } + case JS.Float32Array: { + return Float32Array.from(source as ReadonlyArray); + } + case JS.Float64Array: { + return Float64Array.from(source as ReadonlyArray); + } + case JS.BigInt64Array: { + return BigInt64Array.from(source as ReadonlyArray); + } + case JS.BigUint64Array: { + return BigUint64Array.from(source as ReadonlyArray); + } + case JS.DataView: { + const [v] = source as [Uint8Array]; + return new DataView(v.buffer, v.byteOffset, v.byteLength); + } + case JS.Map: { + return new Map(source); + } + case JS.Set: { + return new Set(source); + } + default: { + throw new Error(`Unknown data type: ${type}`); + } + } +} + +export const JavaScriptCodec: ExtensionCodecType = (() => { + const ext = new ExtensionCodec(); + + ext.register({ + type: EXT_JAVASCRIPT, + encode: encodeJavaScriptStructure, + decode: decodeJavaScriptStructure, + }); + + return ext; +})(); diff --git a/src/index.ts b/src/index.ts index 2d3a5b9b..9851819f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,16 @@ export { encodeTimestampExtension, decodeTimestampExtension, }; + +import { + JavaScriptCodec, + EXT_JAVASCRIPT, + encodeJavaScriptStructure, + decodeJavaScriptStructure, +} from "./JavaScriptCodec"; +export { + JavaScriptCodec, + EXT_JAVASCRIPT, + encodeJavaScriptStructure, + decodeJavaScriptStructure, +}; diff --git a/test/javascript-codec.test.ts b/test/javascript-codec.test.ts new file mode 100644 index 00000000..9bfe094b --- /dev/null +++ b/test/javascript-codec.test.ts @@ -0,0 +1,54 @@ +import assert from "assert"; +import { encode, decode, JavaScriptCodec } from "@msgpack/msgpack"; + +describe("JavaScriptCodec", () => { + context("mixed", () => { + it("encodes and decodes structured data", () => { + const object = { + // basic + str: "string", + num: 0, + obj: { foo: "foo", bar: "bar" }, + arr: [1, 2, 3], + bool: true, + nil: null, + + // JavaScript structures + date: new Date("Thu, 28 Apr 2016 22:02:17 GMT"), + regexp: /foo\n/i, + arrayBuffer: Uint8Array.from([0, 1, 2, 0xff]).buffer, + int8Array: Int8Array.from([0, 1, 2, 0xff]), + uint8ClampedArray: Uint8ClampedArray.from([0, 1, 2, 0xff]), + int16Array: Int16Array.from([0, 1, 2, 0xffff]), + uint16Array: Uint16Array.from([0, 1, 2, -1]), + int32Array: Int32Array.from([0, 1, 2, 0xffff]), + uint32Array: Uint32Array.from([0, 1, 2, -1]), + float32Array: Float32Array.from([0, 1, 2, Math.PI, Math.E]), + float64Array: Float64Array.from([0, 1, 2, Math.PI, Math.E]), + map: new Map([["foo", 10], ["bar", 20]]), + set: new Set([123, 456]), + }; + const encoded = encode(object, { extensionCodec: JavaScriptCodec }); + assert.deepStrictEqual(decode(encoded, { extensionCodec: JavaScriptCodec }), object); + }); + }); + + context("bigint and its family", () => { + it("encodes and decodes structured data", function () { + if (typeof BigInt === "undefined" || typeof BigInt64Array === "undefined" || typeof BigUint64Array === "undefined") { + this.skip(); + } + + const object = { + bigint: BigInt(42), + bigintArray: [BigInt(42)], + + // TODO: + //bigInt64array: BigInt64Array.from([BigInt(0), BigInt(Number.MIN_SAFE_INTEGER) - BigInt(1)]), + //bigUint64array: BigUint64Array.from([BigInt(0), BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)]), + }; + const encoded = encode(object, { extensionCodec: JavaScriptCodec }); + assert.deepStrictEqual(decode(encoded, { extensionCodec: JavaScriptCodec }), object); + }); + }); +});