diff --git a/package.json b/package.json index 04887c8..83e346b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typed-binary", - "version": "2.0.0", + "version": "2.1.0", "description": "Describe binary structures with full TypeScript support. Encode and decode into pure JavaScript objects.", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/src/io/bufferReader.ts b/src/io/bufferReader.ts index bd58b65..2a2ca47 100644 --- a/src/io/bufferReader.ts +++ b/src/io/bufferReader.ts @@ -50,4 +50,8 @@ export class BufferReader extends BufferIOBase implements ISerialInput { return contents; } + + skipBytes(bytes: number): void { + this.byteOffset += bytes; + } } diff --git a/src/io/bufferWriter.ts b/src/io/bufferWriter.ts index 5f71f1a..af90beb 100644 --- a/src/io/bufferWriter.ts +++ b/src/io/bufferWriter.ts @@ -46,4 +46,8 @@ export class BufferWriter extends BufferIOBase implements ISerialOutput { // Extra null character this.uint8View[this.byteOffset++] = 0; } + + skipBytes(bytes: number): void { + this.byteOffset += bytes; + } } diff --git a/src/io/index.ts b/src/io/index.ts index ee79c4c..17ad8f8 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,4 +1,5 @@ -export { ISerialInput, ISerialOutput } from './types'; +export { ISerialInput, ISerialOutput, IMeasurer } from './types'; export { BufferWriter } from './bufferWriter'; export { BufferReader } from './bufferReader'; -export { isBigEndian } from '../util'; \ No newline at end of file +export { Measurer } from './measurer'; +export { isBigEndian } from '../util'; diff --git a/src/io/measurer.ts b/src/io/measurer.ts new file mode 100644 index 0000000..65822c1 --- /dev/null +++ b/src/io/measurer.ts @@ -0,0 +1,34 @@ +import { IMeasurer } from './types'; + +class UnboundedMeasurer implements IMeasurer { + size = NaN; + unbounded: IMeasurer = this; + isUnbounded = true; + + add(): IMeasurer { + return this; + } + + fork(): IMeasurer { + return this; + } +} + +const unboundedMeasurer = new UnboundedMeasurer(); + +export class Measurer implements IMeasurer { + size = 0; + unbounded: IMeasurer = unboundedMeasurer; + isUnbounded = false; + + add(bytes: number): IMeasurer { + this.size += bytes; + return this; + } + + fork() { + const forked = new Measurer(); + forked.size = this.size; + return forked; + } +} diff --git a/src/io/types.ts b/src/io/types.ts index a4962cd..d498c18 100644 --- a/src/io/types.ts +++ b/src/io/types.ts @@ -5,6 +5,7 @@ export interface ISerialInput { readUint32(): number; readFloat32(): number; readString(): string; + skipBytes(bytes: number): void; readonly currentByteOffset: number; } @@ -15,5 +16,15 @@ export interface ISerialOutput { writeUint32(value: number): void; writeFloat32(value: number): void; writeString(value: string): void; + skipBytes(bytes: number): void; readonly currentByteOffset: number; } + +export interface IMeasurer { + add(bytes: number): IMeasurer; + fork(): IMeasurer; + readonly unbounded: IMeasurer; + + readonly size: number; + readonly isUnbounded: boolean; +} diff --git a/src/structure/array.ts b/src/structure/array.ts index da75db2..d51fe88 100644 --- a/src/structure/array.ts +++ b/src/structure/array.ts @@ -1,5 +1,10 @@ -import type { ISerialInput, ISerialOutput } from '../io'; -import { i32 } from './baseTypes'; +import { + type ISerialInput, + type ISerialOutput, + type IMeasurer, + Measurer, +} from '../io'; +import { u32 } from './baseTypes'; import { IRefResolver, ISchema, @@ -43,19 +48,23 @@ export class ArraySchema extends Schema { return array; } - sizeOf(values: T[] | typeof MaxValue): number { + measure( + values: T[] | typeof MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { if (values === MaxValue) { // arrays cannot be bound - return NaN; + return measurer.unbounded; } // Length encoding - let size = i32.sizeOf(); + u32.measure(values.length, measurer); + // Values encoding - size += values - .map((v) => this.elementType.sizeOf(v)) - .reduce((a, b) => a + b, 0); + for (const value of values) { + this.elementType.measure(value, measurer); + } - return size; + return measurer; } } diff --git a/src/structure/baseTypes.ts b/src/structure/baseTypes.ts index c06cd60..3ad7a5b 100644 --- a/src/structure/baseTypes.ts +++ b/src/structure/baseTypes.ts @@ -1,4 +1,4 @@ -import { ISerialInput, ISerialOutput } from '../io'; +import { ISerialInput, ISerialOutput, IMeasurer, Measurer } from '../io'; import { Schema, MaxValue } from './types'; //// @@ -18,8 +18,11 @@ export class BoolSchema extends Schema { output.writeBool(value); } - sizeOf(): number { - return 1; + measure( + _: boolean | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return measurer.add(1); } } @@ -42,12 +45,15 @@ export class StringSchema extends Schema { output.writeString(value); } - sizeOf(value: T | typeof MaxValue): number { + measure( + value: string | typeof MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { if (value === MaxValue) { // A string cannot be bound - return NaN; + return measurer.unbounded; } - return value.length + 1; + return measurer.add(value.length + 1); } } @@ -70,8 +76,11 @@ export class ByteSchema extends Schema { output.writeByte(value); } - sizeOf(): number { - return 1; + measure( + _: number | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return measurer.add(1); } } @@ -94,8 +103,11 @@ export class Int32Schema extends Schema { output.writeInt32(value); } - sizeOf(): number { - return 4; + measure( + _: number | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return measurer.add(4); } } @@ -118,8 +130,11 @@ export class Uint32Schema extends Schema { output.writeUint32(value); } - sizeOf(): number { - return 4; + measure( + _: number | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return measurer.add(4); } } @@ -142,8 +157,11 @@ export class Float32Schema extends Schema { output.writeFloat32(value); } - sizeOf(): number { - return 4; + measure( + _: number | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return measurer.add(4); } } diff --git a/src/structure/chars.ts b/src/structure/chars.ts index 50d8b91..8188882 100644 --- a/src/structure/chars.ts +++ b/src/structure/chars.ts @@ -1,35 +1,39 @@ -import type { ISerialInput, ISerialOutput } from '../io'; +import type { IMeasurer, ISerialInput, ISerialOutput } from '../io'; import { TypedBinaryError } from '../error'; import { Schema } from './types'; export class CharsSchema extends Schema { - constructor(public readonly length: number) { - super(); - } + constructor(public readonly length: number) { + super(); + } - resolve(): void { /* Nothing to resolve */ } + resolve(): void { + /* Nothing to resolve */ + } - write(output: ISerialOutput, value: string): void { - if (value.length !== this.length) { - throw new TypedBinaryError(`Expected char-string of length ${this.length}, got ${value.length}`); - } - - for (let i = 0; i < value.length; ++i) { - output.writeByte(value.charCodeAt(i)); - } + write(output: ISerialOutput, value: string): void { + if (value.length !== this.length) { + throw new TypedBinaryError( + `Expected char-string of length ${this.length}, got ${value.length}`, + ); } - read(input: ISerialInput): string { - let content = ''; - - for (let i = 0; i < this.length; ++i) { - content += String.fromCharCode(input.readByte()); - } - - return content; + for (let i = 0; i < value.length; ++i) { + output.writeByte(value.charCodeAt(i)); } + } + + read(input: ISerialInput): string { + let content = ''; - sizeOf(): number { - return this.length; + for (let i = 0; i < this.length; ++i) { + content += String.fromCharCode(input.readByte()); } + + return content; + } + + measure(_: string, measurer: IMeasurer): IMeasurer { + return measurer.add(this.length); + } } diff --git a/src/structure/index.ts b/src/structure/index.ts index 82f8550..36dbf93 100644 --- a/src/structure/index.ts +++ b/src/structure/index.ts @@ -9,6 +9,8 @@ export { Ref, IRefResolver, Schema, + ISchema, + IStableSchema, ISchemaWithProperties, Keyed, KeyedSchema, diff --git a/src/structure/keyed.ts b/src/structure/keyed.ts index 76328d6..bcfd78b 100644 --- a/src/structure/keyed.ts +++ b/src/structure/keyed.ts @@ -1,6 +1,6 @@ import { Parsed } from '..'; import { TypedBinaryError } from '../error'; -import { ISerialInput, ISerialOutput } from '../io'; +import { IMeasurer, ISerialInput, ISerialOutput, Measurer } from '../io'; import { IRefResolver, ISchema, @@ -36,9 +36,9 @@ class RefSchema implements IStableSchema> { ); } - sizeOf(): number { + measure(): IMeasurer { throw new TypedBinaryError( - `Tried to estimate size of a reference directly. Resolve it instead.`, + `Tried to measure size of a reference directly. Resolve it instead.`, ); } } @@ -107,7 +107,10 @@ export class KeyedSchema> this.innerType.write(output, value); } - sizeOf(value: Parsed | typeof MaxValue): number { - return this.innerType.sizeOf(value); + measure( + value: Parsed | typeof MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + return this.innerType.measure(value, measurer); } } diff --git a/src/structure/object.ts b/src/structure/object.ts index eb215a3..6123ac1 100644 --- a/src/structure/object.ts +++ b/src/structure/object.ts @@ -1,5 +1,10 @@ -import type { ISerialInput, ISerialOutput } from '../io'; -import { string } from './baseTypes'; +import { + Measurer, + type IMeasurer, + type ISerialInput, + type ISerialOutput, +} from '../io'; +import { byte, string } from './baseTypes'; import { Schema, IRefResolver, @@ -74,12 +79,15 @@ export class ObjectSchema return result; } - sizeOf(value: I | typeof MaxValue): number { - return exactEntries(this.properties) - .map(([key, property]) => - property.sizeOf(value == MaxValue ? MaxValue : value[key]), - ) // Mapping properties into their sizes. - .reduce((a, b) => a + b, 0); // Summing them up + measure( + value: T | typeof MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + for (const [key, property] of exactEntries(this.properties)) { + property.measure(value === MaxValue ? MaxValue : value[key], measurer); + } + + return measurer; } } @@ -186,29 +194,52 @@ export class GenericObjectSchema< return result; } - sizeOf(value: GenericInfered): number { - let size = this._baseObject.sizeOf(value as T); + measure( + value: GenericInfered | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + this._baseObject.measure(value as T | MaxValue, measurer); // We're a generic object trying to encode a concrete value. - size += - this.keyedBy === SubTypeKey.ENUM - ? 1 - : string.sizeOf(value.type as string); + if (this.keyedBy === SubTypeKey.ENUM) { + byte.measure(0, measurer); + } else if (value !== MaxValue) { + string.measure(value.type as string, measurer); + } else { + // 'type' can be a string of any length, so the schema is unbounded. + return measurer.unbounded; + } // Extra sub-type fields - const subTypeDescription = this.subTypeMap[value.type] || null; - if (subTypeDescription === null) { - throw new Error( - `Unknown sub-type '${value.type.toString()}' in among '${JSON.stringify( - Object.keys(this.subTypeMap), - )}'`, - ); - } + if (value === MaxValue) { + const biggestSubType = (Object.values(this.subTypeMap) as E[keyof E][]) + .map((subType) => { + const forkedMeasurer = measurer.fork(); + + Object.values(subType.properties) // Going through extra properties + .forEach((prop) => prop.measure(MaxValue, forkedMeasurer)); // Measuring them - size += exactEntries(subTypeDescription.properties) // Going through extra property keys - .map(([key, prop]) => prop.sizeOf(value[key])) // Mapping extra properties into their sizes - .reduce((a, b) => a + b, 0); // Summing them up + return [subType, forkedMeasurer.size] as const; + }) + .reduce((a, b) => (a[1] > b[1] ? a : b))[0]; + + // Measuring for real this time + Object.values(biggestSubType.properties) // Going through extra properties + .forEach((prop) => prop.measure(MaxValue, measurer)); + } else { + const subTypeDescription = this.subTypeMap[value.type] || null; + if (subTypeDescription === null) { + throw new Error( + `Unknown sub-type '${value.type.toString()}', expected one of '${JSON.stringify( + Object.keys(this.subTypeMap), + )}'`, + ); + } + + exactEntries(subTypeDescription.properties) // Going through extra properties + .forEach(([key, prop]) => prop.measure(value[key], measurer)); // Measuring them + } - return size; + return measurer; } } diff --git a/src/structure/optional.ts b/src/structure/optional.ts index 64d9a43..a92e067 100644 --- a/src/structure/optional.ts +++ b/src/structure/optional.ts @@ -1,4 +1,9 @@ -import type { ISerialInput, ISerialOutput } from '../io'; +import { + Measurer, + type IMeasurer, + type ISerialInput, + type ISerialOutput, +} from '../io'; import { IRefResolver, ISchema, @@ -41,9 +46,14 @@ export class OptionalSchema extends Schema { return undefined; } - sizeOf(value: T | undefined | typeof MaxValue): number { - if (value === undefined) return 1; + measure( + value: T | typeof MaxValue | undefined, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + if (value !== undefined) { + this.innerSchema.measure(value, measurer); + } - return 1 + this.innerSchema.sizeOf(value); + return measurer.add(1); } } diff --git a/src/structure/tuple.ts b/src/structure/tuple.ts index b52e1a8..2f0511d 100644 --- a/src/structure/tuple.ts +++ b/src/structure/tuple.ts @@ -6,7 +6,12 @@ import { MaxValue, Schema, } from './types'; -import type { ISerialInput, ISerialOutput } from '../io'; +import { + Measurer, + type IMeasurer, + type ISerialInput, + type ISerialOutput, +} from '../io'; export class TupleSchema extends Schema { private elementSchema: IStableSchema; @@ -48,13 +53,17 @@ export class TupleSchema extends Schema { return array; } - sizeOf(values: T[] | typeof MaxValue): number { - if (values === MaxValue) { - return this.length * this.elementSchema.sizeOf(MaxValue); + measure( + values: T[] | MaxValue, + measurer: IMeasurer = new Measurer(), + ): IMeasurer { + for (let i = 0; i < this.length; ++i) { + this.elementSchema.measure( + values === MaxValue ? MaxValue : values[i], + measurer, + ); } - return values - .map((v) => this.elementSchema.sizeOf(v)) - .reduce((a, b) => a + b); + return measurer; } } diff --git a/src/structure/types.ts b/src/structure/types.ts index 123d743..456f6ff 100644 --- a/src/structure/types.ts +++ b/src/structure/types.ts @@ -1,5 +1,6 @@ -import { ISerialInput, ISerialOutput } from '../io'; +import { ISerialInput, ISerialOutput, IMeasurer } from '../io'; +export type MaxValue = typeof MaxValue; export const MaxValue = Symbol('The maximum value a schema can hold'); /** @@ -20,7 +21,7 @@ export interface IStableSchema extends ISchema { resolve(ctx: IRefResolver): void; write(output: ISerialOutput, value: I): void; read(input: ISerialInput): I; - sizeOf(value: I | typeof MaxValue): number; + measure(value: I | typeof MaxValue, measurer?: IMeasurer): IMeasurer; } export abstract class Schema implements IStableSchema { @@ -29,7 +30,7 @@ export abstract class Schema implements IStableSchema { abstract resolve(ctx: IRefResolver): void; abstract write(output: ISerialOutput, value: I): void; abstract read(input: ISerialInput): I; - abstract sizeOf(value: I | typeof MaxValue): number; + abstract measure(value: I | typeof MaxValue, measurer?: IMeasurer): IMeasurer; } export class Ref { diff --git a/src/test/_mock.test.ts b/src/test/_mock.test.ts index 5c82afd..76e886b 100644 --- a/src/test/_mock.test.ts +++ b/src/test/_mock.test.ts @@ -3,17 +3,20 @@ import { IStableSchema } from '../structure/types'; import { Parsed } from '../utilityTypes'; export function makeIO(bufferSize: number) { - const buffer = Buffer.alloc(bufferSize); - return { - output: new BufferWriter(buffer), - input: new BufferReader(buffer), - }; + const buffer = Buffer.alloc(bufferSize); + return { + output: new BufferWriter(buffer), + input: new BufferReader(buffer), + }; } -export function encodeAndDecode>(schema: T, value: Parsed): Parsed { - const buffer = Buffer.alloc(schema.sizeOf(value)); +export function encodeAndDecode>( + schema: T, + value: Parsed, +): Parsed { + const buffer = Buffer.alloc(schema.measure(value).size); - schema.write(new BufferWriter(buffer), value); + schema.write(new BufferWriter(buffer), value); - return schema.read(new BufferReader(buffer)) as Parsed; + return schema.read(new BufferReader(buffer)) as Parsed; } diff --git a/src/test/array.test.ts b/src/test/array.test.ts index 4a433f6..79d9fcc 100644 --- a/src/test/array.test.ts +++ b/src/test/array.test.ts @@ -1,9 +1,9 @@ import * as chai from 'chai'; + +import { ArraySchema, i32, MaxValue } from '../structure'; +import { arrayOf } from '../describe'; import { randIntBetween } from './random'; import { makeIO } from './_mock.test'; -import { ArraySchema, i32 } from '../structure'; -import { arrayOf } from '..'; -import { MaxValue } from '../structure/types'; const expect = chai.expect; @@ -17,15 +17,15 @@ describe('ArraySchema', () => { values.push(randIntBetween(-10000, 10000)); } - expect(IntArray.sizeOf(values)).to.equal( - i32.sizeOf() + length * i32.sizeOf(), + expect(IntArray.measure(values).size).to.equal( + i32.measure(MaxValue).size + length * i32.measure(MaxValue).size, ); }); it('should fail to estimate size of max value', () => { const IntArray = arrayOf(i32); - expect(IntArray.sizeOf(MaxValue)).to.be.NaN; + expect(IntArray.measure(MaxValue).isUnbounded).to.be.true; }); it('should encode and decode a simple int array', () => { diff --git a/src/test/object.test.ts b/src/test/object.test.ts index 2afcc4e..8020712 100644 --- a/src/test/object.test.ts +++ b/src/test/object.test.ts @@ -1,12 +1,21 @@ import * as chai from 'chai'; import { encodeAndDecode, makeIO } from './_mock.test'; -import { i32, string } from '../structure/baseTypes'; import { generic, genericEnum, object, optional } from '../describe'; +import { byte, i32, string, MaxValue } from '../structure'; import { Parsed } from '../utilityTypes'; const expect = chai.expect; describe('ObjectSchema', () => { + it('should properly estimate size of max value', () => { + const description = object({ + value: i32, + label: byte, + }); + + expect(description.measure(MaxValue).size).to.equal(5); + }); + it('should encode and decode a simple object', () => { const description = object({ value: i32, @@ -112,7 +121,7 @@ describe('ObjectSchema', () => { c: 3, }; - const { output, input } = makeIO(schema.sizeOf(value)); + const { output, input } = makeIO(schema.measure(value).size); schema.write(output, value); expect(input.readInt32()).to.equal(1); // a