spun - TypeScript binary layout engine

Binary as a structured document.

spun builds composable, inspectable in-memory layout graphs. Instead of writing bytes immediately, you construct a graph of typed nodes, resolve it, and emit - or parse any binary file back into the same graph using a scheme.

$ npm install @gottheflag/spun

Most binary tooling forces a choice: write bytes immediately, or parse into plain objects. spun does neither. A layout is a retained graph - you can inspect it, edit it, resolve references across it, and emit to bytes only when ready. Parsing a file produces the same kind of graph, giving you a uniform API for both reading and writing.

1
Retained graph
Nothing is written immediately. Every call builds a typed node in memory.
2
Two directions
layout emits binary. scheme parses binary back into a live layout.
3
Symbolic refs
Reference sections by node. Offsets resolve automatically at emit time.
4
Deferred values
Reserve space now, fill checksums or sizes in after the layout is built.
5
Inspectable
inspect() returns a JSON-serializable tree of every node with offsets and sizes.
6
Extensible
Subclass LayoutNode to create custom types that integrate with the full API.

Installation

spun has zero runtime dependencies. It targets ES2022 and works in Node.js, Bun, Deno, browsers, and workers. No node:buffer dependency.

# npm / pnpm / bun
npm install @gottheflag/spun
pnpm add @gottheflag/spun
bun add @gottheflag/spun
import {
  layout, section, emit,
  u8, u16, u32, i8, i16, i32,
  bytes, tag, pad, align,
  ref, reserve,
  scheme, field, sizeof,
  inspect, treeview, hexdump, serialize,
  registerNode, LayoutNode,
} from "@gottheflag/spun";

The Pipeline

Every spun workflow follows three clean stages, always separated. You never skip from construction to bytes without the resolution step in between.

01 - CONSTRUCTION
Build the graph
Compose nodes and sections into a retained layout. No bytes written.
02 - RESOLUTION
Compute geometry
Offsets, alignments, symbolic refs, and padding are resolved across the graph.
03 - EMISSION
Output binary
Call emit(layout) to get a Uint8Array. Resolution runs automatically.
const file = layout(                          // 1. construction
  section("header", tag("EXE"), u16(1), pad(2)),
);

const buf = emit(file);                        // 2 + 3. resolve + emit
// buf → Uint8Array [45 58 45 00 01 00 00 00]

Use Cases

Integer Nodes

Six unsigned and signed integer primitives. All are little-endian. The value argument is optional and defaults to 0 - useful when defining scheme templates where values come from the parsed buffer.

Function Size Range
u8(value?) 1 byte 0 - 255
u16(value?) 2 bytes 0 - 65 535
u32(value?) 4 bytes 0 - 4 294 967 295
i8(value?) 1 byte -128 - 127
i16(value?) 2 bytes -32 768 - 32 767
i32(value?) 4 bytes -2 147 483 648 - 2 147 483 647
const a = u8(255);
const b = u16(0x0102);    // emits: 02 01 (little-endian)
const c = u32(0xDEADBEEF);
const d = i32(-1);

a.size;   // 1
a.value;  // 255

// in scheme context - value filled by load()
const slot = u32();  // defaults to 0

bytes()

Holds a raw binary blob. Four call signatures cover emit and parse contexts:

Signature Context Behaviour
bytes(data: Uint8Array) Emit Size and content from data.
bytes(n: number) Both Fixed n-byte slot.
bytes(() => node.value) Parse Lazy - size evaluated at parse time.
bytes(sizeof("field")) Scheme Size from a named field() in the same scheme.
bytes() Parse Remainder - consumes all remaining bytes.
const data = new Uint8Array([0xAA, 0xBB, 0xCC]);

bytes(data);             // emit: 3 bytes from data
bytes(16);               // fixed: always 16 bytes
bytes(() => n.value);    // lazy: size from another node at parse time
bytes(sizeof("size"));   // scheme: size from a named field
bytes();                 // remainder: consumes rest of buffer

tag()

Encodes an ASCII string literal as raw bytes - one byte per character. Commonly used for binary magic numbers, format signatures, and chunk identifiers.

tag(source: string | number): TagNode

Called with a string - size is the string length, value is the string. Called with a number - reserves that many bytes for parsing (scheme use).

tag("BM");     // 2 bytes: 42 4D
tag("TEX0");   // 4 bytes
tag("PNG\r\n\x1A\n"); // 8-byte PNG signature

// scheme: read 4 bytes as a tag string
tag(4);

pad() and align()

pad inserts exactly n zero bytes. align inserts however many zero bytes are needed to bring the cursor to the next multiple of the boundary - computed during resolution.

pad(size: number): PadNode
align(boundary: number): AlignNode
const file = layout(
  tag("PNG"),   // @0, size 3 → cursor at 3
  align(4),     // inserts 1 byte → cursor at 4
  u32(1),       // @4
  pad(8),       // always exactly 8 zero bytes
);
Note

AlignNode.size is 0 at construction time. The real padding size is computed during resolution when the node's offset is known.

ref()

A symbolic reference to another node. During resolution, ref(target) becomes a 4-byte little-endian unsigned integer holding the byte offset of target in the final layout.

ref(target: LayoutNode): RefNode
const body = section("body", bytes(pixelData));

const file = layout(
  tag("TEX0"),         // 4 bytes @0
  ref(body),           // 4 bytes @4 → resolves to body's offset (8)
  body,                // @8
);

emit(file);
// bytes 4-7: 08 00 00 00 (little-endian offset of body)
Important

The target must exist somewhere in the same layout. If it cannot be found during resolution, an error is thrown.

reserve

Allocates a fixed-size slot whose value is not known at construction time. Call .set(value) at any point before emitting. Initial value is 0.

reserve.u8() → ReserveNode (1 byte)
reserve.u16() → ReserveNode (2 bytes)
reserve.u32() → ReserveNode (4 bytes)
const checksum = reserve.u32();
const fileSize = reserve.u32();

const file = layout(tag("EXE"), fileSize, checksum, bytes(body));

// first pass - compute sizes
const temp = emit(file);
fileSize.set(temp.byteLength);
checksum.set(computeCRC32(temp));

// final pass - correct values baked in
const final = emit(file);

layout()

The root container for emission. Holds an ordered sequence of nodes and sections. layout is emit-only - it carries real values and produces binary via emit().

layout(...nodes: LayoutNode[]): Layout
const file = layout(
  section("header", tag("BM"), u32(102)),
  section("body",   bytes(data)),
);

file.nodes;   // ReadonlyArray<LayoutNode>
file.size;    // total byte size (AlignNode contributes 0 before resolution)
Design

layout is a pure construction container - it does not emit, parse, or carry a buffer. Call emit(layout) to produce bytes. Use scheme.load(buffer) to parse bytes back into a layout.

section()

A named group of nodes. Sections are themselves LayoutNode instances - they can be nested arbitrarily and appear anywhere nodes are accepted.

section(name: string, ...nodes: LayoutNode[]): SectionNode

Section names must match /^[A-Za-z0-9_-]{1,1024}$/. This constraint is validated at construction time.

const header = section("file-header",
  tag("BM"), u32(0), u32(0),
);

// nested sections
const chunks = section("chunks",
  section("chunk-a", u32(10), bytes(data)),
  section("chunk-b", u32(20)),
);

emit()

Resolves the layout graph and serializes it to a Uint8Array. This is the only function that produces binary output. Resolution (offsets, alignments, references) happens automatically.

emit(root: Layout): Uint8Array
const buf = emit(file);

// use anywhere Uint8Array is accepted
fs.writeFileSync("output.bin", buf);
socket.send(buf);
new Blob([buf]);
Multiple calls

emit() can be called multiple times. Each call re-resolves from the current node values. For layouts with reserve nodes, the typical pattern is: emit once to compute sizes → set reserved values → emit again for the final binary.

resolve()

Runs the resolution phase explicitly and returns an OffsetMap - a Map<LayoutNode, number> from every node to its byte offset. emit() calls this internally; expose it directly for tooling.

resolve(root: Layout): OffsetMap
const offsets = resolve(file);
offsets.get(myNode);  // → number

scheme()

A structural template that describes the shape of a binary format. No values - only types and sizes. Calling scheme.load(buffer) produces an independent, fully populated Layout.

scheme(...nodes: LayoutNode[]): Scheme
Concept Description
layout Carries real values. Used to construct and emit binary.
scheme Carries no values. Reusable parsing template.
const bmpScheme = scheme(
  section("file-header",
    tag(2),                         // read 2 bytes as a tag
    field("fileSize",    u32()),
    u32(),                           // reserved - no name needed
    field("pixelOffset", u32()),
  ),
  section("dib-header",
    u32(),
    field("width",     i32()),
    field("height",    i32()),
    u16(), field("bpp", u16()),
    u32(), field("imageSize", u32()),
    i32(), i32(), u32(), u32(),
  ),
  section("pixels",
    bytes(sizeof("imageSize")),     // size from named field
  ),
);

field()

Wraps any node with a name, making it addressable by layout.get() and usable as a size source for sizeof(). Transparent in size and emission - delegates entirely to its inner node.

field(name: string, node: LayoutNode): FieldNode

Field names must match /^[A-Za-z0-9_-]{1,1024}$/, validated at construction time.

field("fileSize",    u32())  // named u32 slot
field("pixel-data", bytes(sizeof("imageSize")))

After scheme.load(), access values via layout.get("fieldName") and mutate with .set(value).

sizeof()

Scheme-only. A lazy size reference that resolves to the parsed value of the named field() when load() runs. The correct way to express variable-length blobs in schemes.

sizeof(name: string): SizeofRef
const s = scheme(
  field("dataSize", u32()),
  bytes(sizeof("dataSize")),  // reads dataSize bytes
);

// buffer: [03 00 00 00 AA BB CC]
const l = s.load(buf);
l.get("dataSize").value;  // 3
// bytes node holds [AA, BB, CC]
Scheme only

sizeof() is not valid in layout context. An error is thrown if the referenced field name is not found in the scheme at load time.

scheme.load()

Parses a Uint8Array against the scheme and returns a fully populated, independent Layout. Each call produces a completely separate layout - the scheme itself is never mutated.

scheme.load(buffer: Uint8Array): Layout
const bmp1 = bmpScheme.load(buffer1);
const bmp2 = bmpScheme.load(buffer2);

// independent - mutating bmp1 does not affect bmp2
bmp1.get("width").set(800);
bmp2.get("width").value;  // unchanged

// the result is a normal Layout - all APIs work
emit(bmp1);
inspect(bmp1);
treeview(inspect(bmp1));

layout.get()

Unified path and offset accessor. Works on any Layout - including those produced by scheme.load().

Signature Returns Description
get(path) LayoutNode Node at dot-separated path. Throws if not found.
get(path with *, max?) LayoutNode[] Wildcard - all matching nodes, up to max.
get(offset) LayoutNode Node at exact byte offset. Throws if not found.
get(start, end) LayoutNode[] All nodes with range [start, end).
const bmp = bmpScheme.load(buffer);

// by name - searches all sections
bmp.get("width").value;

// by dot path
bmp.get("dib-header.width").value;
bmp.get("file-header");              // → SectionNode

// by byte offset
bmp.get(18);                          // → node at byte 18

// by byte range
bmp.get(0, 14);                       // → all nodes in [0, 14)

// wildcard - all fields named "size"
bmp.get("*.size");                    // → LayoutNode[]

// wildcard with max results
bmp.get("*.size", 3);               // → LayoutNode[] (max 3)
Path syntax

Each segment must match /^[A-Za-z0-9_-]{1,1024}$/. Use * as a wildcard segment to match any name. Dot notation separates levels: "section-name.field-name". TypeScript's return type is automatically narrowed - paths containing * return LayoutNode[], all others return LayoutNode.

field.set()

Mutates the value of a named field in place. Works on any FieldNode returned by layout.get(). The next emit() call uses the updated value.

fieldNode.set(value: number): void
const bmp = bmpScheme.load(buffer);

bmp.get("dib-header.width").value;   // 4
bmp.get("dib-header.width").set(800);

const out = emit(bmp);               // width field now 800

// wildcard set - all "size" fields to 0
(bmp.get("*.size") as FieldNode[]).forEach(f => f.set(0));

inspect()

The core inspection primitive. Returns a JSON-serializable tree describing the layout's complete structure - every section and node with offsets, sizes, types, labels, and values.

inspect(root: Layout): Inspector

Inspector is InspectorNode[]. Each element is either an InspectorSection or an InspectorLeaf:

// InspectorSection
{
  kind:   "section",
  name:   "file-header",
  offset: 0,
  size:   14,
  nodes:  InspectorNode[],
}

// InspectorLeaf
{
  kind:   "leaf",
  type:   "u32",        // "u8" | "u16" | "u32" | "i8" | ... | "bytes" | "tag" | ...
  label:  "U32(102)",
  offset: 2,
  size:   4,
  value:  102,          // number | string | number[] | null
}
const tree = inspect(layout);

// fully JSON-serializable
const json = JSON.stringify(tree, null, 2);
fs.writeFileSync("layout.json", json);

// navigate programmatically
tree[0].kind;                 // "section"
tree[0].nodes[0].type;        // "tag"
tree[0].nodes[0].value;       // "BM"
tree[0].nodes[0].offset;      // 0
Philosophy

inspect() is the guaranteed contract. It returns raw structured data - how you present it is entirely up to you. Use treeview() and hexdump() for quick terminal output, or build your own GUI renderer, diff tool, or binary visualizer on top of the Inspector tree.

treeview()

Convenience printer. Takes an Inspector and prints a tree to the console. A thin helper built on top of inspect() - not a core primitive.

treeview(inspector: Inspector): void
treeview(inspect(layout));

// Layout
// ├─ Section("file-header")  @0000  +14
// │  ├─ Tag("BM")                    @0000  +2
// │  ├─ U32(102) (fileSize)          @0002  +4
// │  └─ U32(54)  (pixelOffset)       @000a  +4
// └─ Section("pixels")  @000e  +48
//    └─ Bytes(48)                    @000e  +48

hexdump()

Convenience printer. Takes an Inspector and prints a classic hex dump to the console, reconstructing byte values from the inspect tree. 16 bytes per row.

hexdump(inspector: Inspector): void
hexdump(inspect(layout));

// 00000000  42 4D 66 00 00 00 00 00  00 00 36 00 00 00 28 00
// 00000010  00 00 04 00 00 00 04 00  00 00 01 00 18 00 00 00
// ...

serialize()

Restores a fully live Layout from an Inspector tree or a JSON string. The restored layout is fully functional - emittable, inspectable, and accessible via get().

serialize(input: Inspector | string): Layout
// from Inspector object
const tree     = inspect(layout);
const restored = serialize(tree);

// from JSON string - full round-trip
const json      = JSON.stringify(inspect(layout));
const fromJson  = serialize(json);

// fully live
emit(restored);           // → identical Uint8Array
inspect(restored);        // → same tree
restored.get("width");   // → FieldNode
ref() after serialize

RefNode instances are restored as plain u32 nodes holding the resolved offset value. The symbolic edge is not preserved - only the resolved state is captured. Custom nodes require registration via registerNode() to survive serialization.

Custom Nodes

Extend LayoutNode to create fully integrated custom node types. They participate in emit(), inspect(), treeview(), hexdump(), serialize(), and scheme parsing automatically.

Contract

Method Required Description
readonly size: number Yes Byte length of this node. Must be a fixed value.
_emit(view, offset) Yes Write this node's bytes into view at offset.
_load(buffer, offset, remaining) For scheme parsing Read and store value from buffer at offset.
_inspect() Recommended Return { type, label, value } for inspection output.
static fromInspect(node) For serialize() Reconstruct an instance from an InspectorLeaf.
import { LayoutNode, registerNode } from "@gottheflag/spun";
import type { InspectorLeaf } from "@gottheflag/spun";

class XoredNode extends LayoutNode {
  readonly size = 4;
  value: number;
  key:   number;

  constructor(value: number, key: number) {
    super();
    this.value = value;
    this.key   = key;
  }

  // write XOR-obfuscated value
  _emit(view: DataView, offset: number): void {
    view.setUint32(offset, this.value ^ this.key, true);
  }

  // read and de-obfuscate
  _load(buffer: Uint8Array, offset: number): void {
    const raw = new DataView(buffer.buffer).getUint32(offset, true);
    this.value = raw ^ this.key;
  }

  // inspection output
  _inspect() {
    return {
      type:  "xored",
      label: `XOR(${this.value}, key=${this.key})`,
      value: { raw: this.value, key: this.key },
    };
  }

  // restore from serialized JSON
  static fromInspect(node: InspectorLeaf): XoredNode {
    const v = node.value as { raw: number; key: number };
    return new XoredNode(v.raw, v.key);
  }
}

registerNode("xored", XoredNode);

// use it
const node = new XoredNode(0xDEAD, 0xFF);
const file = layout(tag("XOR"), node);
emit(file);    // node emits XOR-obfuscated bytes
inspect(file); // _inspect() output appears in tree

// full round-trip through JSON
const restored = serialize(JSON.stringify(inspect(file)));
emit(restored); // identical bytes

registerNode() / resolveNode()

The node registry maps string keys to custom node constructors. Used by serialize() to reconstruct custom nodes from JSON. The key must match the type field returned by _inspect().

registerNode(key: string, ctor: typeof LayoutNode): void
resolveNode(key: string): typeof LayoutNode | undefined
registerNode("crc32",    CRC32Node);
registerNode("lz4-block", LZ4BlockNode);

// throws if key is already registered
registerNode("crc32", AnotherNode); // Error!

// look up - used internally by serialize()
resolveNode("crc32"); // → CRC32Node constructor

Guide: Emit a BMP File

Produces a valid, openable 24-bit BMP image. Demonstrates layout, section, tag, u16, u32, i32, bytes, reserve, and emit.

import { writeFileSync } from "fs";
import { layout, section, tag, u16, u32, i32, bytes, reserve, emit } from "@gottheflag/spun";

const width  = 4;
const height = 4;

// build pixel data - BGR, bottom-to-top row order, rows padded to 4 bytes
const rowSize   = width * 3;                          // 12 bytes, already aligned
const pixelData = new Uint8Array(rowSize * height);
// ... fill pixelData with BGR values ...

const fileSize    = reserve.u32();
const pixelOffset = reserve.u32();

const file = layout(
  section("file-header",
    tag("BM"),
    fileSize,
    u32(0),           // reserved
    pixelOffset,
  ),
  section("dib-header",
    u32(40),           // BITMAPINFOHEADER size
    i32(width),
    i32(height),
    u16(1),            // color planes
    u16(24),           // bits per pixel
    u32(0),            // compression: none
    u32(pixelData.byteLength),
    i32(2835), i32(2835), // ~72 DPI
    u32(0), u32(0),
  ),
  section("pixels", bytes(pixelData)),
);

// resolve deferred fields
const temp = emit(file);
fileSize.set(temp.byteLength);
pixelOffset.set(54);  // 14 (file header) + 40 (DIB header)

writeFileSync("output.bmp", emit(file));

Guide: Parse a BMP File

Define a scheme once, load any conforming BMP into an inspectable, editable layout.

import { readFileSync, writeFileSync } from "fs";
import {
  scheme, section, field, sizeof,
  tag, u16, u32, i32, bytes,
  emit, inspect, treeview,
} from "@gottheflag/spun";

const bmpScheme = scheme(
  section("file-header",
    tag(2),
    field("fileSize",    u32()),
    u32(),
    field("pixelOffset", u32()),
  ),
  section("dib-header",
    u32(),
    field("width",     i32()),
    field("height",    i32()),
    u16(), field("bpp", u16()),
    u32(), field("imageSize", u32()),
    i32(), i32(), u32(), u32(),
  ),
  section("pixels", bytes(sizeof("imageSize"))),
);

// parse
const buf = readFileSync("output.bmp");
const bmp = bmpScheme.load(new Uint8Array(buf));

// inspect
treeview(inspect(bmp));

// read
bmp.get("width").value;    // 4
bmp.get("height").value;   // 4
bmp.get("bpp").value;      // 24

// modify and re-emit
bmp.get("dib-header.width").set(800);
bmp.get("dib-header.height").set(600);
writeFileSync("modified.bmp", emit(bmp));

// persist the layout as JSON
import { serialize } from "@gottheflag/spun";
const json     = JSON.stringify(inspect(bmp));
const restored = serialize(json);
emit(restored);  // identical bytes

Guide: Custom CRC32 Node

A complete custom node that stores a CRC32 checksum. Integrates with emit, inspect, and serialize.

import { LayoutNode, registerNode, layout, tag, bytes, emit } from "@gottheflag/spun";
import type { InspectorLeaf } from "@gottheflag/spun";

function crc32(data: Uint8Array): number {
  // your CRC32 implementation here
  let crc = 0xFFFFFFFF;
  for (const byte of data) {
    crc ^= byte;
    for (let i = 0; i < 8; i++)
      crc = (crc >>> 1) ^ (0xEDB88320 & -(crc & 1));
  }
  return (crc ^ 0xFFFFFFFF) >>> 0;
}

class CRC32Node extends LayoutNode {
  readonly size = 4;
  value = 0;

  _emit(view: DataView, offset: number): void {
    view.setUint32(offset, this.value, true);
  }

  _load(buffer: Uint8Array, offset: number): void {
    this.value = new DataView(buffer.buffer).getUint32(offset, true);
  }

  _inspect() {
    return {
      type:  "crc32",
      label: `CRC32(0x${this.value.toString(16).padStart(8, "0")})`,
      value: this.value,
    };
  }

  static fromInspect(node: InspectorLeaf): CRC32Node {
    const n = new CRC32Node();
    n.value = node.value as number;
    return n;
  }
}

registerNode("crc32", CRC32Node);

// usage
const data     = new Uint8Array([1, 2, 3, 4]);
const checksum = new CRC32Node();

const file = layout(tag("DAT"), checksum, bytes(data));

checksum.value = crc32(data);

const out = emit(file);
// [44 41 54 | crc32 bytes | 01 02 03 04]

Copyright © GTF.