Skip to content

Commit

Permalink
feat(json-pack): 🎸 add RESP v2 encoding ability
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Dec 23, 2023
1 parent 655614e commit d03bb03
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 0 deletions.
10 changes: 10 additions & 0 deletions src/json-pack/resp/RespEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type {Slice} from '../../util/buffers/Slice';
const REG_RN = /[\r\n]/;
const isSafeInteger = Number.isSafeInteger;

/**
* Implements RESP3 encoding.
*/
export class RespEncoder<W extends IWriter & IWriterGrowable = IWriter & IWriterGrowable>
implements BinaryJsonEncoder, StreamingBinaryJsonEncoder, TlvBinaryJsonEncoder
{
Expand Down Expand Up @@ -244,6 +247,13 @@ export class RespEncoder<W extends IWriter & IWriterGrowable = IWriter & IWriter
writer.u16(RESP.RN); // \r\n
}

public writeSimpleStrAscii(str: string): void {
const writer = this.writer;
writer.u8(RESP.STR_SIMPLE); // +
writer.ascii(str);
writer.u16(RESP.RN); // \r\n
}

public writeBulkStr(str: string): void {
const writer = this.writer;
const size = utf8Size(str);
Expand Down
96 changes: 96 additions & 0 deletions src/json-pack/resp/RespEncoderLegacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {RESP} from './constants';
import {RespAttributes, RespPush, RespVerbatimString} from './extensions';
import {JsonPackExtension} from '../JsonPackExtension';
import {RespEncoder} from './RespEncoder';
import type {IWriter, IWriterGrowable} from '../../util/buffers';

const REG_RN = /[\r\n]/;
const isSafeInteger = Number.isSafeInteger;

/**
* Implements RESP v2 encoding.
*/
export class RespEncoderLegacy<W extends IWriter & IWriterGrowable = IWriter & IWriterGrowable> extends RespEncoder<W> {
public writeAny(value: unknown): void {
switch (typeof value) {
case 'number':
return this.writeNumber(value as number);
case 'string':
return this.writeStr(value);
case 'boolean':
return this.writeSimpleStr(value ? 'TRUE' : 'FALSE');
case 'object': {
if (!value) return this.writeNull();
if (value instanceof Array) return this.writeArr(value);
if (value instanceof Uint8Array) return this.writeBin(value);
if (value instanceof Error) return this.writeErr(value.message);
if (value instanceof Set) return this.writeSet(value);
if (value instanceof JsonPackExtension) {
if (value instanceof RespPush) return this.writeArr(value.val);
if (value instanceof RespVerbatimString) return this.writeStr(value.val);
if (value instanceof RespAttributes) return this.writeObj(value.val);
}
return this.writeObj(value as Record<string, unknown>);
}
case 'undefined':
return this.writeUndef();
case 'bigint':
return this.writeSimpleStrAscii(value + '');
default:
return this.writeUnknown(value);
}
}

public writeNumber(num: number): void {
if (isSafeInteger(num)) this.writeInteger(num);
else this.writeSimpleStrAscii(num + '');
}

public writeStr(str: string): void {
const length = str.length;
if (length < 64 && !REG_RN.test(str)) this.writeSimpleStr(str);
else this.writeBulkStr(str);
}

public writeNull(): void {
this.writeNullArr();
}

public writeErr(str: string): void {
if (str.length < 64 && !REG_RN.test(str)) this.writeSimpleErr(str);
else this.writeBulkStr(str);
}

public writeSet(set: Set<unknown>): void {
this.writeArr([...set]);
}

public writeArr(arr: unknown[]): void {
const writer = this.writer;
const length = arr.length;
writer.u8(RESP.ARR); // *
this.writeLength(length);
writer.u16(RESP.RN); // \r\n
for (let i = 0; i < length; i++) {
const val = arr[i];
if (val === null) this.writeNullStr();
else this.writeAny(val);
};
}

public writeObj(obj: Record<string, unknown>): void {
const writer = this.writer;
const keys = Object.keys(obj);
const length = keys.length;
writer.u8(RESP.ARR); // %
this.writeLength(length << 1);
writer.u16(RESP.RN); // \r\n
for (let i = 0; i < length; i++) {
const key = keys[i];
this.writeStr(key);
const val = obj[key];
if (val === null) this.writeNullStr();
else this.writeAny(val);
}
}
}
52 changes: 52 additions & 0 deletions src/json-pack/resp/__tests__/RespEncoderLegacy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {RespEncoderLegacy} from '../RespEncoderLegacy';

const encode = (value: unknown): string => {
const encoder = new RespEncoderLegacy();
const encoded = encoder.encode(value);
return Buffer.from(encoded).toString();
};

test('can encode simple strings', () => {
expect(encode('')).toBe('+\r\n');
expect(encode('asdf')).toBe('+asdf\r\n');
});

test('can encode simple errors', () => {
expect(encode(new Error('asdf'))).toBe('-asdf\r\n');
});

test('can encode integers', () => {
expect(encode(0)).toBe(':0\r\n');
expect(encode(123)).toBe(':123\r\n');
expect(encode(-422469777)).toBe(':-422469777\r\n');
});

test('can encode bulk strings', () => {
expect(encode('ab\nc')).toBe('$4\r\nab\nc\r\n');
expect(encode(new Uint8Array([65]))).toBe('$1\r\nA\r\n');
});

test('can encode arrays', () => {
expect(encode(['a', 1])).toBe('*2\r\n+a\r\n:1\r\n');
});

test('encodes null as nullable array', () => {
expect(encode(null)).toBe('*-1\r\n');
});

test('encodes null in nested structure as nullable string', () => {
expect(encode(['a', 'b', null])).toBe('*3\r\n+a\r\n+b\r\n$-1\r\n');
});

test('encodes booleans as strings', () => {
expect(encode(true)).toBe('+TRUE\r\n');
expect(encode(false)).toBe('+FALSE\r\n');
});

test('encodes floats as strings', () => {
expect(encode(1.23)).toBe('+1.23\r\n');
});

test('encodes objects as 2-tuple arrays', () => {
expect(encode({foo: 'bar'})).toBe('*2\r\n+foo\r\n+bar\r\n');
});
2 changes: 2 additions & 0 deletions src/json-pack/resp/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './constants';
export * from './extensions';
export * from './RespEncoder';
export * from './RespEncoderLegacy';
export * from './RespDecoder';
export * from './RespStreamingDecoder';

0 comments on commit d03bb03

Please sign in to comment.