From f30b2ae175b648618bc88159aa2cf1e33bb1aa0a Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Mon, 12 Nov 2018 22:05:47 +0000 Subject: [PATCH] Initial JavaScript (node) implementation --- implementations/javascript/.gitignore | 2 + implementations/javascript/package.json | 26 ++ implementations/javascript/src/index.js | 342 ++++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 implementations/javascript/.gitignore create mode 100644 implementations/javascript/package.json create mode 100644 implementations/javascript/src/index.js diff --git a/implementations/javascript/.gitignore b/implementations/javascript/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/implementations/javascript/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/implementations/javascript/package.json b/implementations/javascript/package.json new file mode 100644 index 0000000..81e1add --- /dev/null +++ b/implementations/javascript/package.json @@ -0,0 +1,26 @@ +{ + "name": "preserves", + "version": "0.0.0", + "description": "Data serialization format", + "homepage": "https://gitlab.com/tonyg/preserves", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": "gitlab:tonyg/preserves", + "scripts": { + "test": "mocha", + "cover": "nyc --reporter=html mocha" + }, + "main": "src/index.js", + "author": "Tony Garnock-Jones ", + "devDependencies": { + "chai": "^4.2.0", + "chai-immutable": "^2.0.0-rc.2", + "mocha": "^5.2.0", + "nyc": "^13.1.0" + }, + "dependencies": { + "immutable": "^3.8.2" + } +} diff --git a/implementations/javascript/src/index.js b/implementations/javascript/src/index.js new file mode 100644 index 0000000..ad0bbd5 --- /dev/null +++ b/implementations/javascript/src/index.js @@ -0,0 +1,342 @@ +"use strict"; + +const Immutable = require('immutable'); +const { List, Map, Set, fromJS } = Immutable; + +const PreserveOn = Symbol('PreserveOn'); + +class DecodeError extends Error {} +class EncodeError extends Error { + constructor(message, irritant) { + super(message); + this.irritant = irritant; + } +} + +class Decoder { + constructor(packet, options) { + options = options || {}; + this.packet = packet; + this.index = 0; + this.shortForms = options.shortForms || {}; + } + + peekbyte() { + if (this.index >= this.packet.length) throw new DecodeError("Short packet"); + // ^ NOTE: greater-than-or-equal-to, not greater-than. + return this.packet[this.index]; + } + + advance(count) { + const start = this.index; + this.index += (count === void 0 ? 1 : count); + return start; + } + + nextbyte() { + const val = this.peekbyte(); + this.advance(); + return val; + } + + wirelength(arg) { + if (arg < 15) return arg; + return this.varint(); + } + + varint() { + // TODO: Bignums :-/ + const v = this.nextbyte(); + if (v < 128) return v; + return this.varint() * 128 + (v - 128); + } + + nextbytes(n) { + const start = this.advance(n); + if (this.index > this.packet.length) throw new DecodeError("Short packet"); + // ^ NOTE: greater-than, not greater-than-or-equal-to. + return this.packet.slice(start, this.index); // pretend it is immutable, please + } + + nextvalues(n) { + const result = []; + for (let i = 0; i < n; i++) result.push(this.next()); + return result; + } + + peekop() { + const b = this.peekbyte(); + const major = b >> 6; + const minor = (b >> 4) & 3; + const arg = b & 15; + return [major, minor, arg]; + } + + nextop() { + const op = this.peekop(); + this.advance(); + return op; + } + + peekend(arg) { + const [a,i,r] = this.peekop(); + return (a === 0) && (i === 3) && (r === arg); + } + + binarystream(arg, minor) { + const result = []; + while (!this.peekend(arg)) { + chunk = this.next(); + if (!Buffer.isBuffer(chunk)) throw new DecodeError("Unexpected non-binary chunk"); + result.push(chunk); + } + return Buffer.concat(result); // pretend it is immutable, please + } + + valuestream(arg, minor, decoder) { + const result = []; + while (!this.peekend(arg)) result.push(this.next()); + return decoder(minor, result); + } + + decodeint(bs) { + // TODO: Bignums :-/ + if (bs.length === 0) return 0; + let acc = bs[0]; + if (acc & 0x80) acc -= 256; + for (let i = 1; i < bs.length; i++) acc = (acc << 8) | b[i]; + return acc; + } + + decodebinary(minor, bs) { + switch (minor) { + case 0: return this.decodeint(bs); + case 1: return bs.toString('utf-8'); + case 2: return bs; // again, pretend it is immutable, please + case 3: return Symbol.for(bs.toString('utf-8')); + } + } + + decoderecord(minor, vs) { + if (minor === 3) { + if (vs.length === 0) throw new DecodeError("Too few elements in encoded record"); + return new Record(vs[0], vs.slice(1)); + } else { + const label = shortForms[minor]; + if (label === void 0) throw new DecodeError("Use of unconfigured short form " + minor); + return new Record(label, vs); + } + } + + decodecollection(minor, vs) { + switch (minor) { + case 0: return List(vs); + case 1: return Set(vs); + case 2: return mapFromArray(vs); + case 3: throw new DecodeError("Invalid collection type"); + } + } + + next() { + const [major, minor, arg] = this.nextop(); + switch (major) { + case 0: + switch (minor) { + case 0: + switch (arg) { + case 0: return false; + case 1: return true; + case 2: return Float(this.nextbytes(4).readFloatBE()); + case 3: return this.nextbytes(8).readDoubleBE(); + } + case 1: + return (arg > 12) ? arg - 16 : arg; + case 2: { + const t = arg >> 2; + const n = arg & 3; + switch (t) { + case 0: throw new DecodeError("Invalid format C start byte"); + case 1: return this.binarystream(arg, n); + case 2: return this.valuestream(arg, n, this.decoderecord.bind(this)); + case 3: return this.valuestream(arg, n, this.decodecollection.bind(this)); + } + } + case 3: + throw new DecodeError("Invalid format C end byte"); + } + case 1: return this.decodebinary(minor, this.nextbytes(this.wirelength(arg))); + case 2: return this.decoderecord(minor, this.nextvalues(this.wirelength(arg))); + case 3: return this.decodecollection(minor, this.nextvalues(this.wirelength(arg))); + } + } +} + +class Encoder { + constructor(options) { + options = options || {} + this.chunks = []; + this.buffer = Buffer.alloc(256); + this.index = 0; + this.shortForms = options.shortForms || {}; + } + + contents() { + this.rotatebuffer(4096); + return Buffer.concat(this.chunks); + } + + rotatebuffer(size) { + this.chunks.push(this.buffer.slice(0, this.index)); + this.buffer = Buffer.alloc(size); + this.index = 0; + } + + makeroom(amount) { + if (this.index + amount > this.buffer.length) { + this.rotatebuffer(amount + 4096); + } + } + + emitbyte(b) { + this.makeroom(1); + this.buffer[this.index++] = b; + } + + emitbytes(bs) { + this.makeroom(bs.length); + this.buffer.fill(bs, this.index, this.index + bs.length); + this.index += bs.length; + } + + varint(v) { + if (v < 128) { + this.emitbyte(v); + } else { + this.emitbyte((v % 128) + 128); + this.varint(Math.floor(v / 128)); + } + } + + leadbyte(major, minor, arg) { + this.emitbyte(((major & 3) << 6) | ((minor & 3) << 4) | (arg & 15)); + } + + header(major, minor, wirelength) { + if (wirelength < 15) { + this.leadbyte(major, minor, wirelength); + } else { + this.leadbyte(major, minor, 15); + this.varint(wirelength); + } + } + + encodeint(v) { + // TODO: Bignums :-/ + const plain_bitcount = Math.floor(Math.log2(Math.abs(v))) + 1; + const signed_bitcount = plain_bitcount + 1; + const bytecount = (signed_bitcount + 7) >> 3; + this.header(1, 0, bytecount); + const enc = (n, x) => { + if (n > 0) { + enc(n - 1, x >> 8); + this.emitbyte(x & 255); + } + }; + enc(bytecount, v); + } + + encodecollection(minor, items) { + this.header(3, minor, items.size); + for (const item of items) { this.push(item); } + } + + encodestream(t, n, items) { + const tn = ((t & 3) << 2) | (n & 3); + this.header(0, 2, tn); + for (const item of items) { this.push(item); } + this.header(0, 3, tn); + } + + push(v) { + if (typeof v === 'object' && v !== null && typeof v[PreserveOn] === 'function') { + v[PreserveOn](this); + } + if (v === false) return this.leadbyte(0, 0, 0); + if (v === true) return this.leadbyte(0, 0, 1); + if (typeof v === 'number') { + if (v >= -3 && v <= 12) return this.leadbyte(0, 1, v >= 0 ? v : v + 16); + return this.encodeint(v); + } + if (typeof v === 'string') { + const bs = Buffer.from(v); + this.header(1, 1, bs.length); + return this.emitbytes(bs); + } + if (typeof v === 'symbol') { + const key = Symbol.keyFor(v); + if (key === void 0) throw new EncodeError("Cannot preserve non-global Symbol", v); + const bs = Buffer.from(key); + this.header(1, 3, bs.length); + return this.emitbytes(bs); + } + if (Buffer.isBuffer(v)) { + this.header(1, 2, v.length); + return this.emitbytes(v); + } + if (List.isList(v)) return this.encodecollection(0, v); + if (Set.isSet(v)) return this.encodecollection(1, v); + if (Map.isMap(v)) { + const kvs = []; + v.forEach((val, key) => { kvs.push(key); kvs.push(val); }); + return this.encodecollection(2, kvs); + } + if (typeof v === 'object' && v !== null && typeof v[Symbol.iterator] === 'function') { + return this.encodestream(3, 0, v); + } + throw new EncodeError("Cannot encode", v); + } +} + +class Record { + constructor(label, fields) { + this.label = label; + this.fields = fromJS(fields); + } + + [PreserveOn](encoder) { + if (Immutable.is(encoder.shortForms[0], this.label)) { + encoder.header(2, 0, this.fields.size); + } else if (Immutable.is(encoder.shortForms[1], this.label)) { + encoder.header(2, 1, this.fields.size); + } else if (Immutable.is(encoder.shortForms[2], this.label)) { + encoder.header(2, 2, this.fields.size); + } else { + encoder.header(2, 3, this.fields.size + 1); + encoder.push(this.label); + } + for (const field in this.fields) { encoder.push(field); } + } +} + +class Float { + constructor(value) { + this.value = value; + } + + [PreserveOn](encoder) { + encoder.leadbyte(0, 0, 2); + encoder.makeroom(4); + encoder.buffer.writeFloatBE(this.value, encoder.index); + encoder.index += 4; + } +} + +Object.assign(module.exports, { + PreserveOn, + DecodeError, + EncodeError, + Decoder, + Encoder, + Record, + Float, +});