diff --git a/CHANGELOG.md b/CHANGELOG.md index bce1784..762fac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.17.0-pre — 2026-04-12 + +- Add variable-length `string` field: `byteLength` is now optional and defaults + to the end of the struct's buffer. +- Add `bytes` field: returns a live `Uint8Array` view into the struct's buffer, + with optional fixed or variable length. + ## 0.16.2 — 2026-04-05 - Fix npm package imports under Node by shipping JavaScript entrypoints instead diff --git a/deno.json b/deno.json index 4cef62b..19e1b10 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@rotu/structview", - "version": "0.16.2", + "version": "0.17.0-pre", "license": "MIT", "tasks": { "validate": "deno run -A scripts/sync-package-metadata.ts --check && deno fmt --check && deno lint && deno x vitest run && deno publish --dry-run --allow-dirty && npm pack --dry-run && npm run smoke:npm" diff --git a/fields.ts b/fields.ts index eaf0a40..d4cdb57 100644 --- a/fields.ts +++ b/fields.ts @@ -228,34 +228,61 @@ export function f64(fieldOffset: number): StructPropertyDescriptor { } /** - * Field for a UTF-8 fixed-length string + * Field for a UTF-8 string. When `byteLength` is provided, covers exactly that + * many bytes starting at `fieldOffset`. When omitted, extends from `fieldOffset` + * to the end of the struct's buffer (variable-length). */ export function string( fieldOffset: number, - byteLength: number, + byteLength?: number, ): StructPropertyDescriptor { const TEXT_DECODER = new TextDecoder() const TEXT_ENCODER = new TextEncoder() return { get() { + const end = byteLength !== undefined + ? fieldOffset + byteLength + : undefined const str = TEXT_DECODER.decode( - structBytes(this, fieldOffset, fieldOffset + byteLength), + structBytes(this, fieldOffset, end), ) // trim all trailing null characters return str.replace(/\0+$/, "") }, set(value) { - const bytes = structBytes( - this, - fieldOffset, - fieldOffset + byteLength, - ) + const end = byteLength !== undefined + ? fieldOffset + byteLength + : undefined + const bytes = structBytes(this, fieldOffset, end) bytes.fill(0) TEXT_ENCODER.encodeInto(value, bytes) }, } } +/** + * Field for a live `Uint8Array` view into the struct's buffer. When + * `byteLength` is provided, covers exactly that many bytes starting at + * `fieldOffset`. When omitted, extends from `fieldOffset` to the end of the + * struct's buffer (variable-length). The field is read-only; mutations happen + * through the returned `Uint8Array` directly. + */ +export function bytes( + fieldOffset: number, + byteLength?: number, +): + & StructPropertyDescriptor + & ReadOnlyAccessorDescriptor { + return { + get() { + const end = byteLength !== undefined + ? fieldOffset + byteLength + : undefined + return structBytes(this, fieldOffset, end) + }, + } +} + /** * Field for a boolean stored in a byte (0 = false, nonzero = true) * True will be stored as 1 diff --git a/mod_test.ts b/mod_test.ts index 44458a4..7a04b69 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -2,6 +2,7 @@ import { bigintle, biguintle, bool, + bytes, f16, f32, f64, @@ -488,6 +489,67 @@ test("fromDataView with setter is writable and enumerable", () => { assert(keys.includes("val")) }) +test("string variable-length (no byteLength)", () => { + // 4 bytes prefix + 6 bytes for variable-length string + const Cls = defineStruct({ + prefix: u32(0), + name: string(4), + }) + const c = new Cls(new Uint8Array(10)) + deepStrictEqual(c.name, "") + c.name = "hello!" + deepStrictEqual(c.name, "hello!") + // trailing nulls are trimmed + c.name = "hi" + deepStrictEqual(c.name, "hi") + // prefix field is unaffected + c.prefix = 0xdeadbeef + deepStrictEqual(c.prefix, 0xdeadbeef) + deepStrictEqual(c.name, "hi") +}) + +test("bytes fixed-length", () => { + const buf = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) + const Cls = defineStruct({ + data: bytes(2, 4), + }) + const c = new Cls(buf) + expect(c.data).toBeInstanceOf(Uint8Array) + deepStrictEqual(c.data.length, 4) + // is a live view of the same underlying buffer + strictEqual(c.data.buffer, buf.buffer) + deepStrictEqual(c.data.byteOffset, 2) + // mutations through the Uint8Array are reflected in buf + c.data[0] = 0xff + deepStrictEqual(buf[2], 0xff) + // is read-only (no setter) + throws(() => { + // @ts-expect-error assigning to readonly property + c.data = new Uint8Array(4) + }) +}) + +test("bytes variable-length (no byteLength)", () => { + const buf = new Uint8Array([10, 20, 30, 40, 50]) + const Cls = defineStruct({ + data: bytes(2), + }) + const c = new Cls(buf) + expect(c.data).toBeInstanceOf(Uint8Array) + // extends from offset 2 to end of struct + deepStrictEqual(c.data.length, 3) + strictEqual(c.data.buffer, buf.buffer) + deepStrictEqual(c.data.byteOffset, 2) + // mutations through the Uint8Array are reflected in buf + c.data[1] = 0xab + deepStrictEqual(buf[3], 0xab) + // is read-only (no setter) + throws(() => { + // @ts-expect-error assigning to readonly property + c.data = new Uint8Array(3) + }) +}) + function hexToUint8Array(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new TypeError("Hex input must have an even length") diff --git a/package.json b/package.json index 9626b77..95ff53b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rotu/structview", - "version": "0.16.2", + "version": "0.17.0-pre", "description": "Read and write structured binary data with typesafe views", "license": "MIT", "type": "module",