diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5d271ca --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,87 @@ +# Architecture + +This document explains the key design decisions behind structview and the +trade-offs considered. + +## Why property descriptors? + +structview uses `Object.defineProperties()` on the prototype to expose binary +fields as getter/setter pairs. Each field factory (`u8`, `f32`, `string`, …) +returns a standard `PropertyDescriptor`, and `defineStruct()` installs them on +an anonymous subclass of `Struct`. + +Alternatives considered: + +### Proxy-based approach + +A `Proxy` wrapper could intercept property access and dispatch to the underlying +`DataView`. This would make `{...struct}` and `Object.keys()` work transparently +because the proxy's `ownKeys` / `getOwnPropertyDescriptor` traps can advertise +the fields as own properties. + +Downsides: + +- **Performance.** Proxy property access is roughly 5–10× slower than a + prototype getter in V8. For a library whose main value proposition is + zero-copy views of binary data, this is a significant tax. +- **TypeScript ergonomics.** Typing a Proxy so that each field has the correct + type requires mapped-type gymnastics and loses IntelliSense features like + "Go to Definition" on individual fields. +- **Class integration.** Proxies don't compose naturally with `class` syntax, + `instanceof`, or `super`. Users couldn't subclass a struct to add domain + methods without additional boilerplate. +- **Identity.** A proxy wrapping a plain target object complicates `===` + comparisons and WeakMap keying. + +### Instance descriptors (defineProperty on each instance) + +Instead of sharing descriptors on the prototype, each constructor call could +install them on the instance. That would make `{...struct}` work because spread +only copies own properties. + +Downsides: + +- **Memory.** Every instance allocates its own set of descriptor objects. + For struct arrays with thousands of elements, this adds significant GC + pressure. +- **Startup cost.** `Object.defineProperties` on each instance is measurably + slower than a single prototype setup. + +### Conclusion + +Prototype property descriptors offer the best balance of performance, memory +efficiency, TypeScript inference, and composability with the class system. +The main ergonomic gap—`JSON.stringify()` and spread not reflecting inherited +fields—is addressed by providing a `toJSON()` method on `Struct`. + +## What was done well + +1. **Symbol-keyed internal state.** The `DataView` backing store lives behind + `Symbol.for("Struct.dataview")`, so user field names never collide with + internal bookkeeping. +2. **Composable field factories.** Each factory (`u8`, `f32`, `string`, …) is a + pure function returning a standard descriptor. Users can write their own + factories (via `fromDataView`) without touching library internals. +3. **TypeScript integration.** `defineStruct` preserves full type inference— + field types, readonly inference for getter-only descriptors, and constructor + signatures—without requiring separate type declarations. +4. **Zero-copy views.** Struct instances and substructs share the same + `ArrayBuffer`. Mutations are immediately visible across all views. + +## What was improved + +1. **`toJSON()` on `Struct`.** `JSON.stringify(struct)` now works as expected, + serializing all enumerable inherited fields. Nested substructs serialize + recursively. +2. **Gotcha documentation.** The README gotcha about spread/JSON has been updated + to note that `JSON.stringify` now works via `toJSON()`, while spread syntax + still requires a manual `Object.assign({}, ...)` pattern. + +## Remaining limitations + +- **Spread syntax** (`{...struct}`) still copies only own enumerable properties. + Since fields live on the prototype, spread produces an empty object. Use + `struct.toJSON()` to obtain a plain-object snapshot, or + `Object.assign({}, struct.toJSON())` if you need a spreadable copy. +- **`structuredClone()`** does not invoke `toJSON()` and will not preserve field + values. Clone the underlying buffer instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 742ff00..cd92996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.17.0 + +- Add `toJSON()` method to `Struct` so `JSON.stringify(struct)` works out of the + box, serializing all enumerable inherited fields. +- Add `ARCHITECTURE.md` documenting design trade-offs (property descriptors vs + proxies vs instance descriptors). + ## 0.16.1 — 2026-04-02 - Align release publishing so npm and JSR stay version-synchronized. diff --git a/README.md b/README.md index f1f6a34..02198f2 100644 --- a/README.md +++ b/README.md @@ -106,5 +106,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. + `JSON.stringify(s)` _does_ work because `Struct` provides a `toJSON()` + method. To get a spreadable plain object, use `s.toJSON()`. diff --git a/core.ts b/core.ts index f3a5225..d31bd17 100644 --- a/core.ts +++ b/core.ts @@ -74,6 +74,18 @@ export class Struct { return new this({ buffer }) } + /** + * Serialize enumerable fields to a plain object. + * Enables `JSON.stringify(struct)` to include inherited prototype fields. + */ + toJSON(): Record { + const result: Record = {} + for (const key in this) { + result[key] = (this as Record)[key] + } + return result + } + get [Symbol.toStringTag](): string { return Struct.name } diff --git a/mod_test.ts b/mod_test.ts index 44458a4..2f3e4d7 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -488,6 +488,42 @@ test("fromDataView with setter is writable and enumerable", () => { assert(keys.includes("val")) }) +test("toJSON with primitive fields", () => { + class S extends defineStruct({ + x: u8(0), + y: f32(4), + name: string(8, 5), + }) {} + const buf = new Uint8Array(13) + const s = new S(buf) + s.x = 42 + s.y = 1.5 + s.name = "hello" + deepStrictEqual(s.toJSON(), { x: 42, y: 1.5, name: "hello" }) + deepStrictEqual(JSON.parse(JSON.stringify(s)), { x: 42, y: 1.5, name: "hello" }) +}) + +test("toJSON with substruct", () => { + const Point = defineStruct({ x: f32(0), y: f32(4) }) + const Rect = defineStruct({ + origin: substruct(Point, 0, 8), + size: substruct(Point, 8, 8), + }) + const buf = new Float32Array([1, 2, 3, 4]) + const rect = new Rect(buf) + const json = JSON.parse(JSON.stringify(rect)) + deepStrictEqual(json, { + origin: { x: 1, y: 2 }, + size: { x: 3, y: 4 }, + }) +}) + +test("toJSON on empty struct", () => { + const s = new Struct({ buffer: new ArrayBuffer(0) }) + deepStrictEqual(s.toJSON(), {}) + deepStrictEqual(JSON.stringify(s), "{}") +}) + function hexToUint8Array(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new TypeError("Hex input must have an even length")