negentropy/js/Negentropy.js

481 lines
15 KiB
JavaScript
Raw Normal View History

2023-03-10 12:19:11 -05:00
// (C) 2023 Doug Hoyte. MIT license
2023-09-02 04:06:05 -04:00
class WrappedBuffer {
constructor(buffer) {
2023-09-02 04:06:05 -04:00
this._raw = new Uint8Array(buffer || 256);
this.length = buffer ? buffer.length : 0;
}
unwrap() {
2023-09-02 04:06:05 -04:00
return this._raw.subarray(0, this.length);
}
get capacity() {
2023-09-02 04:06:05 -04:00
return this._raw.byteLength;
}
extend(buf) {
2023-09-02 04:06:05 -04:00
if (buf._raw) buf = buf.unwrap();
const targetSize = buf.length + this.length;
if (this.capacity < targetSize) {
const oldRaw = this._raw;
2023-09-02 04:06:05 -04:00
const newCapacity = Math.max(this.capacity * 2, targetSize);
this._raw = new Uint8Array(newCapacity);
this._raw.set(oldRaw);
}
this._raw.set(buf, this.length);
this.length += buf.length;
}
shift() {
2023-09-02 04:06:05 -04:00
const first = this._raw[0];
this._raw = this._raw.subarray(1);
this.length--;
return first;
}
2023-09-02 04:06:05 -04:00
shiftN(n = 1) {
const firstSubarray = this._raw.subarray(0, n);
this._raw = this._raw.subarray(n);
this.length -= n;
return firstSubarray;
}
}
2023-03-10 12:19:11 -05:00
class Negentropy {
2023-09-02 04:06:05 -04:00
constructor(idSize = 16, frameSizeLimit = 0) {
2023-03-10 12:19:11 -05:00
if (idSize < 8 || idSize > 32) throw Error("idSize invalid");
2023-09-02 04:06:05 -04:00
if (frameSizeLimit !== 0 && frameSizeLimit < 4096) throw Error("frameSizeLimit too small");
2023-03-10 12:19:11 -05:00
this.idSize = idSize;
2023-09-02 04:06:05 -04:00
this.frameSizeLimit = frameSizeLimit;
2023-03-10 12:19:11 -05:00
this.items = [];
2023-09-02 04:06:05 -04:00
this.pendingOutputs = [];
2023-03-10 12:19:11 -05:00
}
addItem(timestamp, id) {
if (this.sealed) throw Error("already sealed");
2023-09-02 04:06:05 -04:00
id = this._loadInput(id);
if (id.byteLength > 64 || id.byteLength < this.idSize) throw Error("bad length for id");
if (id.byteLength > this.idSize) id = id.subarray(0, this.idSize);
2023-03-10 12:19:11 -05:00
this.items.push({ timestamp, id });
2023-03-10 12:19:11 -05:00
}
seal() {
if (this.sealed) throw Error("already sealed");
this.items.sort(itemCompare);
this.sealed = true;
}
_newState() {
return {
lastTimestampIn: 0,
lastTimestampOut: 0,
};
}
_zeroBound() {
return { timestamp: 0, id: new Uint8Array(this.idSize) };
}
_maxBound() {
return { timestamp: Number.MAX_VALUE, id: new Uint8Array(0) };
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
_loadInput(inp) {
if (typeof(inp) === 'string') inp = hexToUint8Array(inp);
else if (__proto__ !== Uint8Array.prototype) inp = new Uint8Array(inp); // node Buffer?
return inp;
}
2023-03-10 12:19:11 -05:00
initiate() {
if (!this.sealed) throw Error("not sealed");
this.isInitiator = true;
2023-09-02 04:06:05 -04:00
this.splitRange(0, this.items.length, this._zeroBound(), this._maxBound(), this.pendingOutputs);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
return this.buildOutput();
2023-03-10 12:19:11 -05:00
}
reconcile(query) {
2023-09-02 04:06:05 -04:00
query = new WrappedBuffer(this._loadInput(query));
2023-03-10 12:19:11 -05:00
let haveIds = [], needIds = [];
2023-09-02 04:06:05 -04:00
if (!this.sealed) throw Error("not sealed");
this.continuationNeeded = false;
2023-03-10 12:19:11 -05:00
let prevBound = this._zeroBound();
let prevIndex = 0;
let state = this._newState();
2023-09-02 04:06:05 -04:00
let outputs = [];
2023-03-10 12:19:11 -05:00
while (query.length !== 0) {
let currBound = this.decodeBound(query, state);
2023-09-02 04:06:05 -04:00
let mode = this.decodeVarInt(query); // 0 = Skip, 1 = Fingerprint, 2 = IdList, 3 = deprecated, 4 = Continuation
2023-03-10 12:19:11 -05:00
let lower = prevIndex;
let upper = findUpperBound(this.items, lower, this.items.length, currBound, itemCompare);
if (mode === 0) { // Skip
2023-09-02 04:06:05 -04:00
// Do nothing
2023-03-10 12:19:11 -05:00
} else if (mode === 1) { // Fingerprint
let theirXorSet = this.getBytes(query, this.idSize);
let ourXorSet = new Uint8Array(this.idSize);
2023-03-10 12:19:11 -05:00
for (let i = lower; i < upper; ++i) {
let item = this.items[i];
for (let j = 0; j < this.idSize; j++) ourXorSet[j] ^= item.id[j];
}
let matches = true;
for (let i = 0; i < this.idSize; i++) {
if (theirXorSet[i] !== ourXorSet[i]) {
matches = false;
break;
}
}
if (!matches) {
2023-09-02 04:06:05 -04:00
this.splitRange(lower, upper, prevBound, currBound, outputs);
2023-03-10 12:19:11 -05:00
}
} else if (mode === 2) { // IdList
2023-09-02 04:06:05 -04:00
let numIds = this.decodeVarInt(query);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
let theirElems = {}; // stringified Uint8Array -> original Uint8Array
for (let i = 0; i < numIds; i++) {
let e = this.getBytes(query, this.idSize);
theirElems[e] = e;
2023-03-10 12:19:11 -05:00
}
for (let i = lower; i < upper; i++) {
2023-09-02 04:06:05 -04:00
let k = this.items[i].id;
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
if (!theirElems[k]) {
2023-03-10 12:19:11 -05:00
// ID exists on our side, but not their side
2023-09-02 04:06:05 -04:00
if (this.isInitiator) haveIds.push(this.wantUint8ArrayOutput ? k : uint8ArrayToHex(k));
2023-03-10 12:19:11 -05:00
} else {
// ID exists on both sides
2023-09-02 04:06:05 -04:00
delete theirElems[k];
2023-03-10 12:19:11 -05:00
}
}
2023-09-02 04:06:05 -04:00
if (this.isInitiator) {
for (let v of Object.values(theirElems)) {
needIds.push(this.wantUint8ArrayOutput ? v : uint8ArrayToHex(v));
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
} else {
let responseHaveIds = [];
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
let it = lower;
let didSplit = false;
let splitBound = this._zeroBound();
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
let flushIdListOutput = () => {
let payload = this.encodeVarInt(2); // mode = IdList
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
payload.extend(this.encodeVarInt(responseHaveIds.length));
for (let id of responseHaveIds) payload.extend(id);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
let nextSplitBound = (it+1) >= upper ? currBound : this.getMinimalBound(this.items[it], this.items[it+1]);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
outputs.push({
start: didSplit ? splitBound : prevBound,
end: nextSplitBound,
payload: payload,
});
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
splitBound = nextSplitBound;
didSplit = true;
responseHaveIds = [];
};
for (; it < upper; ++it) {
responseHaveIds.push(this.items[it].id);
if (responseHaveIds.length >= 100) flushIdListOutput(); // 100*32 is less than minimum frame size limit of 4k
}
flushIdListOutput();
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
} else if (mode === 3) { // Deprecated
throw Error("other side is speaking old negentropy protocol");
} else if (mode === 4) { // Continuation
this.continuationNeeded = true;
2023-03-10 12:19:11 -05:00
} else {
throw Error("unexpected mode");
}
prevIndex = upper;
prevBound = currBound;
}
2023-09-02 04:06:05 -04:00
while (outputs.length) {
this.pendingOutputs.unshift(outputs.pop());
}
return [this.buildOutput(), haveIds, needIds];
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
splitRange(lower, upper, lowerBound, upperBound, outputs) {
2023-03-10 12:19:11 -05:00
let numElems = upper - lower;
let buckets = 16;
if (numElems < buckets * 2) {
2023-09-02 04:06:05 -04:00
let payload = this.encodeVarInt(2); // mode = IdList
payload.extend(this.encodeVarInt(numElems));
for (let it = lower; it < upper; ++it) payload.extend(this.items[it].id);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
outputs.push({ start: lowerBound, end: upperBound, payload: payload, });
2023-03-10 12:19:11 -05:00
} else {
let itemsPerBucket = Math.floor(numElems / buckets);
let bucketsWithExtra = numElems % buckets;
let curr = lower;
2023-09-02 04:06:05 -04:00
let prevBound = this.items[curr];
2023-03-10 12:19:11 -05:00
for (let i = 0; i < buckets; i++) {
let ourXorSet = new Uint8Array(this.idSize)
2023-03-10 12:19:11 -05:00
for (let bucketEnd = curr + itemsPerBucket + (i < bucketsWithExtra ? 1 : 0); curr != bucketEnd; curr++) {
for (let j = 0; j < this.idSize; j++) ourXorSet[j] ^= this.items[curr].id[j];
}
2023-09-02 04:06:05 -04:00
let payload = this.encodeVarInt(1); // mode = Fingerprint
payload.extend(ourXorSet);
outputs.push({
start: i === 0 ? lowerBound : prevBound,
end: curr === this.items.length ? upperBound : this.getMinimalBound(this.items[curr - 1], this.items[curr]),
payload: payload,
});
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
prevBound = outputs[outputs.length - 1].end;
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
outputs[outputs.length - 1].end = upperBound;
2023-03-10 12:19:11 -05:00
}
}
2023-09-02 04:06:05 -04:00
buildOutput() {
let output = new WrappedBuffer();
let currBound = this._zeroBound();
let state = this._newState();
this.pendingOutputs.sort((a,b) => itemCompare(a.start, b.start));
while (this.pendingOutputs.length) {
let o = new WrappedBuffer();
let p = this.pendingOutputs[0];
let cmp = itemCompare(p.start, currBound);
// When bounds are out of order or overlapping, finish and resume next time (shouldn't happen because of sort above)
if (cmp < 0) break;
if (cmp !== 0) {
o.extend(this.encodeBound(p.start, state));
o.extend(this.encodeVarInt(0)); // mode = Skip
}
o.extend(this.encodeBound(p.end, state));
o.extend(p.payload);
if (this.frameSizeLimit && output.length + o.length > this.frameSizeLimit - 5) break; // 5 leaves room for Continuation
output.extend(o);
currBound = p.end;
this.pendingOutputs.shift();
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
// Server indicates that it has more to send, OR ensure client sends a non-empty message
if ((!this.isInitiator && this.pendingOutputs.length) || (this.isInitiator && output.length == 0 && this.continuationNeeded)) {
output.extend(this.encodeBound(this._maxBound(), state));
output.extend(this.encodeVarInt(4)); // mode = Continue
}
let ret = output.unwrap();
if (!this.wantUint8ArrayOutput) ret = uint8ArrayToHex(ret);
return ret;
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
// Decoding
2023-03-10 12:19:11 -05:00
getBytes(buf, n) {
if (buf.length < n) throw Error("parse ends prematurely");
2023-09-02 04:06:05 -04:00
return buf.shiftN(n);
2023-03-10 12:19:11 -05:00
}
decodeVarInt(buf) {
let res = 0;
while (1) {
2023-09-02 04:06:05 -04:00
if (buf.length === 0) throw Error("parse ends prematurely");
let byte = buf.shift();
2023-03-10 12:19:11 -05:00
res = (res << 7) | (byte & 127);
if ((byte & 128) === 0) break;
}
return res;
}
decodeTimestampIn(encoded, state) {
let timestamp = this.decodeVarInt(encoded);
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1;
if (state.lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
state.lastTimestampIn = Number.MAX_VALUE;
return Number.MAX_VALUE;
}
timestamp += state.lastTimestampIn;
state.lastTimestampIn = timestamp;
return timestamp;
}
decodeBound(encoded, state) {
let timestamp = this.decodeTimestampIn(encoded, state);
let len = this.decodeVarInt(encoded);
if (len > this.idSize) throw Error("bound key too long");
2023-03-10 12:19:11 -05:00
let id = this.getBytes(encoded, len);
return { timestamp, id };
2023-03-10 12:19:11 -05:00
}
// Encoding
encodeVarInt(n) {
2023-09-02 04:06:05 -04:00
if (n === 0) return new WrappedBuffer([0]);
2023-03-10 12:19:11 -05:00
let o = [];
while (n !== 0) {
o.push(n & 127);
2023-03-10 12:19:11 -05:00
n >>>= 7;
}
o.reverse();
for (let i = 0; i < o.length - 1; i++) o[i] |= 128;
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
return new WrappedBuffer(o);
2023-03-10 12:19:11 -05:00
}
encodeTimestampOut(timestamp, state) {
if (timestamp === Number.MAX_VALUE) {
state.lastTimestampOut = Number.MAX_VALUE;
return this.encodeVarInt(0);
}
let temp = timestamp;
timestamp -= state.lastTimestampOut;
state.lastTimestampOut = temp;
return this.encodeVarInt(timestamp + 1);
}
2023-09-02 04:06:05 -04:00
encodeBound(key, state) {
let output = new WrappedBuffer();
output.extend(this.encodeTimestampOut(key.timestamp, state));
output.extend(this.encodeVarInt(key.id.length));
output.extend(key.id);
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
return output;
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
getMinimalBound(prev, curr) {
2023-03-10 12:19:11 -05:00
if (curr.timestamp !== prev.timestamp) {
2023-09-02 04:06:05 -04:00
return { timestamp: curr.timestamp, id: new Uint8Array(0), };
2023-03-10 12:19:11 -05:00
} else {
let sharedPrefixBytes = 0;
for (let i = 0; i < this.idSize; i++) {
if (curr.id[i] !== prev.id[i]) break;
sharedPrefixBytes++;
}
2023-09-02 04:06:05 -04:00
return { timestamp: curr.timestamp, id: curr.id.subarray(0, sharedPrefixBytes + 1), };
2023-03-10 12:19:11 -05:00
}
};
2023-09-02 04:06:05 -04:00
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
function hexToUint8Array(h) {
if (h.startsWith('0x')) h = h.substr(2);
if (h.length % 2 === 1) throw Error("odd length of hex string");
let arr = new Uint8Array(h.length / 2);
for (let i = 0; i < arr.length; i++) arr[i] = parseInt(h.substr(i * 2, 2), 16);
return arr;
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
const uint8ArrayToHexLookupTable = new Array(256);
{
const hexAlphabet = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
for (let i = 0; i < 256; i++) {
uint8ArrayToHexLookupTable[i] = hexAlphabet[(i >>> 4) & 0xF] + hexAlphabet[i & 0xF];
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
function uint8ArrayToHex(arr) {
let out = '';
for (let i = 0, edx = arr.length; i < edx; i++) {
out += uint8ArrayToHexLookupTable[arr[i]];
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
return out;
2023-03-10 12:19:11 -05:00
}
2023-09-02 04:06:05 -04:00
function compareUint8Array(a, b) {
for (let i = 0; i < a.byteLength; i++) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
2023-03-10 12:19:11 -05:00
2023-09-02 04:06:05 -04:00
if (a.byteLength > b.byteLength) return 1;
if (a.byteLength < b.byteLength) return -1;
return 0;
}
2023-03-10 12:19:11 -05:00
function itemCompare(a, b) {
if (a.timestamp === b.timestamp) {
2023-09-02 04:06:05 -04:00
return compareUint8Array(a.id, b.id);
2023-03-10 12:19:11 -05:00
}
return a.timestamp - b.timestamp;
}
2023-09-02 04:06:05 -04:00
2023-03-10 12:19:11 -05:00
function binarySearch(arr, first, last, cmp) {
let count = last - first;
while (count > 0) {
let it = first;
let step = Math.floor(count / 2);
it += step;
if (cmp(arr[it])) {
first = ++it;
count -= step + 1;
} else {
count = step;
}
}
return first;
}
function findLowerBound(arr, first, last, value, cmp) {
return binarySearch(arr, first, last, (a) => cmp(a, value) < 0);
}
function findUpperBound(arr, first, last, value, cmp) {
return binarySearch(arr, first, last, (a) => cmp(value, a) >= 0);
}
module.exports = Negentropy;