Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,68 @@ for (const dish of myMenu) {
}
```

# Additional field descriptors

## `pad(fieldOffset, byteLength)` — padding / reserved bytes

Document reserved or padding regions without occupying a meaningful field name.
The property is **non-enumerable**, so it is excluded from `JSON.stringify` and
`structToObject`.

```js
import { defineStruct, pad, u8, u32 } from "@rotu/structview"

class Header extends defineStruct({
version: u8(0),
_reserved: pad(1, 3), // 3 reserved bytes
length: u32(4),
}) {
static BYTE_LENGTH = 8
}
```

## `enumField(underlying, values)` — integer-to-label mapping

Map a raw integer field to human-readable string labels. When reading, the
integer is looked up in `values`; unknown integers are returned as-is. When
writing, you may pass a label string or the raw integer.

Inspired by enum types in [kaitai-struct](https://kaitai.io/) and
[restructure](https://github.com/nicolo-ribaudo/restructure).

```js
import { defineStruct, enumField, u8 } from "@rotu/structview"

const STATUS = { 0: "idle", 1: "busy", 2: "error" }

class Packet extends defineStruct({
status: enumField(u8(0), STATUS),
data: u32(4),
}) {
static BYTE_LENGTH = 8
}

const p = Packet.alloc()
p.status = "busy"
console.log(p.status) // "busy"
```

## `structToObject(struct)` — plain-object conversion

Convert any struct to a plain `Record<string, unknown>` by iterating all
enumerable (prototype-defined) fields. Useful for cloning, diffing, or any
place that needs a plain JS object.

```js
import { defineStruct, f32, structToObject } from "@rotu/structview"

class Vec2 extends defineStruct({ x: f32(0), y: f32(4) }) {}
const v = Vec2.alloc({ byteLength: 8 })
v.x = 3; v.y = 4
const plain = structToObject(v) // { x: 3, y: 4 }
console.log(JSON.stringify(plain)) // '{"x":3,"y":4}'
```

# Gotchas and rough edges

1. Resizable structs are not yet implemented. Resizable `Arraybuffer`s only
Expand All @@ -101,5 +163,6 @@ for (const dish of myMenu) {
3. Be careful using `TypedArray`s. They have an alignment requirement relative
to their underlying `ArrayBuffer`.
4. `Struct` classes define properties on the prototype, _not_ on the instance.
That means spread syntax (`x = {...s}`) and `JSON.stringify(s)` will _not_
reflect inherited fields.
That means spread syntax (`x = {...s}`) will _not_ reflect inherited fields.
However, `JSON.stringify(s)` **does** now work because `Struct` implements
`toJSON()`. For spread-like use cases, use the `structToObject(s)` helper.
49 changes: 49 additions & 0 deletions core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,29 @@ export class Struct {
return Struct.name
}

/**
* Return a plain object containing all enumerable struct fields.
*
* Because struct fields are defined on the prototype (not the instance),
* `JSON.stringify(struct)` and spread (`{ ...struct }`) would normally
* produce an empty object. Implementing `toJSON` here means
* `JSON.stringify(struct)` works as expected.
*
* @remarks
* The `for...in` loop is intentional: struct fields are enumerable properties
* on the prototype, not own properties, so we must traverse the prototype
* chain to collect them.
*/
toJSON(): Record<string, unknown> {
const result: Record<string, unknown> = {}
// Intentionally iterates prototype-chain properties: struct fields are
// defined as enumerable descriptors on the prototype by defineStruct().
for (const key in this) {
result[key] = (this as unknown as Record<string, unknown>)[key]
}
return result
}

static toDataView(o: Struct): DataView {
return o[dataViewSymbol]
}
Expand Down Expand Up @@ -138,6 +161,32 @@ export class Struct {
}
}

/**
* Convert a struct to a plain object containing all enumerable field values.
*
* Unlike `JSON.stringify(struct)`, which misses prototype-defined fields, or
* spread (`{ ...struct }`), which only copies own properties, this function
* walks the prototype chain and collects every enumerable field defined by
* `defineStruct`.
*
* @example
* ```ts
* class Point extends defineStruct({ x: f32(0), y: f32(4) }) {}
* const p = Point.alloc({ byteLength: 8 })
* p.x = 1; p.y = 2
* const plain = structToObject(p) // { x: 1, y: 2 }
* ```
*/
export function structToObject(struct: AnyStruct): Record<string, unknown> {
const result: Record<string, unknown> = {}
// Intentionally iterates prototype-chain properties: struct fields are
// defined as enumerable descriptors on the prototype by defineStruct().
for (const key in struct) {
result[key] = (struct as unknown as Record<string, unknown>)[key]
}
return result
}

/**
* Subclass a type by adding the given property descriptors
* @param ctor constructor for the base class
Expand Down
87 changes: 87 additions & 0 deletions fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,93 @@ export function bool(fieldOffset: number): StructPropertyDescriptor<boolean> {
}
}

/**
* Field representing reserved / padding bytes.
*
* Use this to document padding or reserved regions in a struct layout without
* consuming a meaningful field name. The property is non-enumerable so it is
* excluded from `JSON.stringify` and {@link structToObject}.
*
* @example
* ```ts
* class Header extends defineStruct({
* version: u8(0),
* _pad: pad(1, 3), // 3 reserved bytes at offset 1
* length: u32(4),
* }) {}
* ```
*/
export function pad(
fieldOffset: number,
byteLength: number,
): StructPropertyDescriptor<Uint8Array> & ReadOnlyAccessorDescriptor<Uint8Array> {
return {
enumerable: false,
get() {
return structBytes(this, fieldOffset, fieldOffset + byteLength)
},
}
}

/**
* Field that maps an integer value to a string label from a provided enum map.
*
* When reading, the raw integer is looked up in `values`; if a label is found
* it is returned, otherwise the raw integer is returned. When writing, you may
* supply either a label string or the raw integer.
*
* Inspired by enum types in kaitai-struct and restructure.
*
* @param underlying - A numeric field descriptor (e.g. `u8(0)`, `u16(4)`)
* @param values - Map from integer value to label string
*
* @example
* ```ts
* const STATUS = { 0: "idle", 1: "busy", 2: "error" } as const
* class Packet extends defineStruct({
* status: enumField(u8(0), STATUS),
* data: u32(4),
* }) {}
* const p = Packet.alloc({ byteLength: 8 })
* p.status = "busy"
* console.log(p.status) // "busy"
* ```
*/
export function enumField<const Values extends Record<number, string>>(
underlying: StructPropertyDescriptor<number>,
values: Values,
): StructPropertyDescriptor<Values[keyof Values] | number> {
const reverseMap = new Map<string, number>(
Object.entries(values).map(([k, v]) => [v as string, Number(k)]),
)
const validLabels = [...reverseMap.keys()].map((k) => JSON.stringify(k)).join(
", ",
)
return {
get() {
const raw = underlying.get!.call(this)
return (values[raw] ?? raw) as Values[keyof Values] | number
},
set(value) {
let numValue: number
if (typeof value === "string") {
const n = reverseMap.get(value)
if (n === undefined) {
throw new RangeError(
`Unknown enum label: ${
JSON.stringify(value)
}. Valid labels: ${validLabels}`,
)
}
numValue = n
} else {
numValue = value as number
}
underlying.set!.call(this, numValue)
},
}
}

/**
* Define a descriptor based on a dataview of the struct
* @param fieldGetter function which, given a dataview, returns
Expand Down
126 changes: 125 additions & 1 deletion mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import {
bigintle,
biguintle,
bool,
enumField,
f16,
f32,
f64,
i16,
i32,
i64,
i8,
pad,
string,
substruct,
typedArray,
Expand All @@ -17,7 +19,13 @@ import {
u64,
u8,
} from "./fields.ts"
import { defineArray, defineStruct, Struct, structDataView } from "./core.ts"
import {
defineArray,
defineStruct,
Struct,
structDataView,
structToObject,
} from "./core.ts"

import {
assert,
Expand Down Expand Up @@ -449,3 +457,119 @@ Deno.test("alloc", () => {
// ensure correct typing (that alloc doesn't return a bare Struct)
const _zz: Sized = z
})

Deno.test("toJSON", () => {
class Point extends defineStruct({
x: f32(0),
y: f32(4),
}) {}
const p = Point.alloc({ byteLength: 8 })
p.x = 1.5
p.y = -2.5
const json = JSON.stringify(p)
const parsed = JSON.parse(json)
assertEquals(parsed.x, Math.fround(1.5))
assertEquals(parsed.y, Math.fround(-2.5))
})

Deno.test("toJSON nested", () => {
class Inner extends defineStruct({ a: u8(0), b: u8(1) }) {}
class Outer extends defineStruct({
n: u32(0),
inner: substruct(Inner, 4, 2),
}) {}
const o = Outer.alloc({ byteLength: 8 })
o.n = 42
o.inner.a = 7
o.inner.b = 9
const json = JSON.stringify(o)
const parsed = JSON.parse(json)
assertEquals(parsed.n, 42)
assertEquals(parsed.inner.a, 7)
assertEquals(parsed.inner.b, 9)
})

Deno.test("structToObject", () => {
class S extends defineStruct({
x: u32(0),
y: f32(4),
label: string(8, 8),
}) {}
const s = S.alloc({ byteLength: 16 })
s.x = 99
s.y = 3.14
s.label = "hi"
const obj = structToObject(s)
assertEquals(obj.x, 99)
assertEquals(obj.y, Math.fround(3.14))
assertEquals(obj.label, "hi")
// result is a plain object, not a Struct
assertEquals(Object.getPrototypeOf(obj), Object.prototype)
})

Deno.test("pad field is non-enumerable and returns bytes", () => {
class Header extends defineStruct({
version: u8(0),
_reserved: pad(1, 3),
length: u32(4),
}) {
static BYTE_LENGTH = 8
}
const h = Header.alloc()
h.version = 5
h.length = 100

// pad field is non-enumerable (absent from structToObject)
const obj = structToObject(h)
assertEquals(Object.keys(obj), ["version", "length"])

// pad field returns the underlying bytes as a Uint8Array
assertInstanceOf(h._reserved, Uint8Array)
assertEquals(h._reserved.byteLength, 3)

// pad bytes are live (mutations are reflected)
h._reserved[0] = 0xab
assertEquals(h._reserved[0], 0xab)
})

Deno.test("enumField read and write by label", () => {
const STATUS = { 0: "idle", 1: "busy", 2: "error" } as const
class Packet extends defineStruct({
status: enumField(u8(0), STATUS),
data: u32(4),
}) {
static BYTE_LENGTH = 8
}
const p = Packet.alloc()
// default value should be 0 → "idle"
assertEquals(p.status, "idle")

// write by label
p.status = "busy"
assertEquals(p.status, "busy")

// write by label "error"
p.status = "error"
assertEquals(p.status, "error")

// write by raw number
p.status = 0
assertEquals(p.status, "idle")

// unknown raw number returns the number itself
p.status = 99
assertEquals(p.status, 99)
})

Deno.test("enumField throws on unknown label", () => {
const COLOR = { 0: "red", 1: "green", 2: "blue" } as const
class S extends defineStruct({ color: enumField(u8(0), COLOR) }) {}
const s = new S({ byteLength: 1 })
assertThrows(
() => {
// deno-lint-ignore no-explicit-any
s.color = "purple" as any
},
RangeError,
)
})