A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards. OBF (.obf) is a JSON file describing a single communication board: buttons, images, sounds, grid layout, metadata. OBZ (.obz) is a ZIP archive bundling one or more boards with their media and a manifest.json. This package parses, validates, and creates both.
my-board.obz
├── manifest.json ← table of contents: root board + id-to-path maps
├── boards/
│ └── home.obf ← one JSON board per file
├── images/
│ └── dog.png
└── sounds/
└── hello.mp3
- Typed end to end — every type is inferred from a Zod schema, and every schema is exported for
safeParseor composing into your own contracts. - Browser and Node.js 22+ — pure ESM, works against
File,Blob,ArrayBuffer, or any typed-array view (e.g. Node'sBuffer). - One entry point for either format —
loadBoardsniffs the bytes and tells you whether it found an.obfboard or an.obzpackage. - Spec-faithful round trips — unknown fields are preserved rather than stripped, so vendor extensions allowed by the OBF spec survive
parseOBF→stringifyOBF. - Small footprint — one runtime dependency (fflate) plus Zod as a peer, tree-shakeable, no side effects.
Contents: Install · Quick start · Usage · API · Errors · Security · Scope · Versioning · Contributing · Related · License
npm install @shayc/open-board-format zodzod 4 is a peer dependency — npm 7+ installs it automatically, but pnpm and Yarn users should add it explicitly (as shown above).
ESM only — CommonJS (require) is not supported.
import { loadBoard } from "@shayc/open-board-format";
// `file` came from drag-and-drop or <input type="file"> — could be .obf or .obz
const loaded = await loadBoard(file);
if (loaded.format === "obf") {
console.log(loaded.board.buttons.length);
} else {
console.log(loaded.archive.rootBoard.buttons.length);
}loadBoard accepts a File, Blob, ArrayBuffer, or any typed-array view (e.g. a Node Buffer), and throws on invalid input (see Errors). Already holding a JSON string? parseOBF(json) returns a validated OBFBoard directly.
In Node.js, read the file first — there's no File API outside the browser, but readFile's Buffer works directly:
import { readFile } from "node:fs/promises";
import { loadBoard } from "@shayc/open-board-format";
const loaded = await loadBoard(await readFile("my-board.obz"));- You have a single board (OBF). Use
parseOBFfor a JSON string,validateOBFfor an already-parsed object,loadOBFfor a browserFile.stringifyOBFserializes back out. - You have a package of boards plus media (OBZ). Use
extractOBZfor aFile,Blob,ArrayBuffer, or typed-array view (loadOBZis the same thing,File-only);createOBZto build a new one. - You don't know which you have. Use
loadBoard— it sniffs the bytes and returns a{ format, ... }union so you don't have to inspect the file extension yourself.
import { loadOBZ, extractOBZ } from "@shayc/open-board-format";
// From a File (e.g. drag-and-drop)
const { rootBoard, boards, resources } = await loadOBZ(file);
// Or from anything else — ArrayBuffer, Blob, Node Buffer, etc.
const parsed = await extractOBZ(buffer);
// Untrusted input? Cap declared uncompressed sizes (see Security)
const guarded = await extractOBZ(buffer, {
limits: { maxTotalOriginalSize: 500e6 },
});rootBoard is the package's home board — the one manifest.root points at, already resolved. boards is keyed by board ID and resources by archive path; the manifest is also returned if you need the raw table of contents.
Resources are raw bytes. To display an image in the browser:
const bytes = resources.get("images/hello.png")!;
const url = URL.createObjectURL(new Blob([bytes]));Buttons reference media by ID (image_id, sound_id); the board's images/sounds entries carry the archive path; the resources map supplies the bytes for each path. Every path a board declares must have a matching resource entry, or createOBZ throws.
import { createOBZ } from "@shayc/open-board-format";
import type { OBFBoard } from "@shayc/open-board-format";
const board: OBFBoard = {
format: "open-board-0.1",
id: "board-1",
buttons: [{ id: "btn-1", label: "Hello", image_id: "img-1" }],
grid: { rows: 1, columns: 1, order: [["btn-1"]] },
images: [{ id: "img-1", path: "images/hello.png" }],
};
const pngBytes = new Uint8Array(/* ... */);
const resources = new Map([["images/hello.png", pngBytes]]);
const blob = await createOBZ([board], "board-1", resources);The manifest.json is generated for you — boards are written to boards/<id>.obf (the id is percent-encoded, so it's always a safe filename), and rootBoardId (the second argument) selects the home board.
import { OBFBoardSchema } from "@shayc/open-board-format";
const result = OBFBoardSchema.safeParse(data);
if (result.success) {
console.log(result.data.buttons);
} else {
console.error(result.error.issues);
}One naming convention covers the whole surface: parse* takes a JSON string, validate* takes an already-parsed object, load* takes a browser File, stringify* returns a JSON string — and extractOBZ/loadBoard also accept a Blob, ArrayBuffer, or typed-array view (e.g. a Node Buffer), for use outside the browser.
| Function | Returns | Description |
|---|---|---|
parseOBF(json) |
OBFBoard |
Parse a JSON string into a validated OBFBoard |
validateOBF(data) |
OBFBoard |
Validate an unknown object as OBFBoard (throws on failure) |
stringifyOBF(board) |
string |
Serialize an OBFBoard to a JSON string |
loadOBF(file) |
Promise<OBFBoard> |
Load an OBFBoard from a browser File |
| Function | Returns | Description |
|---|---|---|
loadOBZ(file, options?) |
Promise<ParsedOBZ> |
Load an OBZ package from a browser File |
extractOBZ(archive, options?) |
Promise<ParsedOBZ> |
Extract boards, manifest, root board, and resources from a File, Blob, ArrayBuffer, or typed-array view |
createOBZ(boards, rootBoardId, resources?) |
Promise<Blob> |
Create an OBZ package as a Blob |
parseManifest(json) |
OBFManifest |
Parse a manifest.json string into a validated OBFManifest |
| Function | Returns | Description |
|---|---|---|
loadBoard(input, options?) |
Promise<LoadedBoard> |
Detect OBF vs OBZ from a File, Blob, ArrayBuffer, or typed-array view and load it; returns a LoadedBoard union |
| Function | Returns | Description |
|---|---|---|
isZip(archive) |
boolean |
Check if an ArrayBuffer starts with a ZIP magic number |
zip(entries) |
Promise<Uint8Array> |
Create a ZIP from a map of paths to buffers |
unzip(archive, options?) |
Promise<Map<string, Uint8Array>> |
Extract a ZIP into a map of paths to Uint8Array |
| Type | Description |
|---|---|
OBFBoard |
A single communication board |
OBFGrid |
Grid layout (rows, columns, order) |
OBFButton |
A button on the board |
OBFButtonAction |
Button action (spelling or specialty) |
OBFSpellingAction |
Spelling action (e.g., +s) |
OBFSpecialtyAction |
Specialty action (e.g., :clear) |
OBFLoadBoard |
Reference to load another board |
OBFMedia |
Common media properties (base for OBFImage and OBFSound) |
OBFImage |
An image resource (extends OBFMedia) |
OBFSound |
A sound resource (alias of OBFMedia) |
OBFSymbolInfo |
Symbol set reference |
OBFManifest |
OBZ package manifest |
ParsedOBZ |
Return type of extractOBZ / loadOBZ — { manifest, boards, rootBoard, resources } |
LoadedBoard |
Return type of loadBoard — { format: "obz", archive } | { format: "obf", board } |
BinaryInput |
Input type of loadBoard / extractOBZ — File | Blob | ArrayBuffer | ArrayBufferView |
UnzipLimits |
Optional extraction caps — { maxEntrySize?, maxTotalOriginalSize?, maxEntries? } (see Security) |
UnzipOptions |
Options for unzip / extractOBZ / loadOBZ / loadBoard — { limits?: UnzipLimits } |
OBFID |
Unique identifier (string, coerced from number) |
OBFFormatVersion |
Format version string (e.g., open-board-0.1) |
OBFLicense |
Licensing information |
OBFLocaleCode |
BCP 47 locale code |
OBFLocalizedStrings |
Key-value string translations |
OBFStrings |
Multi-locale string translations |
Every type above except ParsedOBZ, LoadedBoard, BinaryInput, UnzipLimits, and UnzipOptions is exported alongside a matching Zod schema with a Schema suffix — OBFBoard → OBFBoardSchema, OBFManifest → OBFManifestSchema, and so on. Import any of them to validate with safeParse/parse or to compose into your own schemas:
import { OBFButtonSchema, OBFManifestSchema } from "@shayc/open-board-format";Every failure throws an OBFError. Branch on error.info.code — a discriminated union where each code carries exactly the fields relevant to that failure. Don't match on error.message; the message is human-readable and may change between releases.
import { loadBoard, OBFError } from "@shayc/open-board-format";
try {
await loadBoard(file);
} catch (error) {
if (!(error instanceof OBFError)) throw error;
switch (error.info.code) {
case "missing-resource":
// `kind`, `mediaId`, and `path` are all typed and present here
console.warn(`Missing ${error.info.kind} at ${error.info.path}`);
break;
case "invalid-board":
// `issues` is the Zod issue list — which field failed and why
console.error(error.info.issues);
break;
default:
console.error(error.message);
}
}The code values, grouped by what they describe:
| Group | info.code |
Key fields (on info) |
|---|---|---|
| Decoding | not-json |
source |
not-zip |
— | |
unreadable-zip |
— | |
archive-too-large |
limit, path, and the tripped cap's fields |
|
| Validation | invalid-board |
issues, boardId? |
invalid-manifest |
issues |
|
| Read (OBZ) | missing-manifest |
— |
missing-board |
boardId, path |
|
board-id-mismatch |
path, declaredId, actualId |
|
| Write (OBZ) | unknown-root |
rootBoardId |
duplicate-board |
boardId |
|
missing-resource |
kind, mediaId, path |
|
conflicting-paths |
kind, mediaId, paths |
|
path-collision |
path |
|
zip-failed |
— | |
| Internal | internal |
detail |
OBFErrorInfo and OBFErrorCode are exported for exhaustive handling.
- The underlying error, when there is one, is always on the standard
error.cause— never duplicated oninfo. - Validation failures (
invalid-board,invalid-manifest) put theZodErroronerror.cause, soz.treeifyError(error.cause)gives nested, UI-friendly output, whileinfo.issues(Zod's issue type, re-exported asOBFIssue) gives you the flat list directly. - For
not-json/*-zipfailures,error.causeis the underlying parser or fflate error. - An
internalcode signals a bug in this library that callers can't recover from — please report it.
Re-zipping an extracted .obz by hand? Zip the folder's contents, not the folder itself. If manifest.json ends up nested inside a top-level folder in the archive, extraction fails with missing-manifest even though the file is right there.
OBZ archives are untrusted input. To guard against zip bombs, pass limits (via UnzipOptions) to loadBoard / loadOBZ / extractOBZ / unzip:
const parsed = await extractOBZ(buffer, {
limits: {
maxEntrySize: 100e6, // any single entry, in bytes
maxTotalOriginalSize: 500e6, // sum of all entries, in bytes
maxEntries: 10_000, // entry count, including directory entries
},
});Size limits are checked per entry against the uncompressed size declared in the archive's ZIP metadata, before that entry is inflated; maxEntries caps how many entries are processed at all. Entries accepted before a later entry trips a limit have already been inflated, but total allocation stays bounded by the caps. Exceeding a limit aborts extraction and rejects with an OBFError whose code is archive-too-large (info carries limit, the entry path that tripped it, and maxBytes/declaredBytes for the size limits or maxEntries/entryCount for the count). No limits are applied by default. A lying header can't force larger allocations — fflate allocates output buffers at exactly the declared size — so the declared-size caps bound memory use.
Entry paths are not sanitized — if you write extracted resources to disk, validate paths yourself first to avoid directory traversal.
Found a security issue? Open a private advisory at github.com/shayc/open-board-format/security/advisories/new.
What this library deliberately does not do:
- No network I/O — media referenced by
urlordata_urlis not fetched; resolving external media is up to you. - No rendering — it parses and validates data; drawing boards and playing sounds belong to your app.
- No default extraction limits, no path sanitization — size caps are opt-in via
UnzipOptions; see Security before writing archive contents to disk. - No referential integrity checks — a
grid.orderid with no matching button, or animage_id/sound_idwith no matching image/sound, is not flagged. Resolving references is up to your rendering layer.
Semver. The public API — every exported function, type, and Zod schema — is stable; breaking changes ship as major releases. See CHANGELOG.md.
See CONTRIBUTING.md for development setup (Node 22+, Vitest, the changeset workflow).
- Open Board Format specification — the official standard and format documentation. A 1:1 mirror is kept at docs/external/open-board-format.md for offline reference.
- AAC Board AI — an offline-first AAC web app built on this package, using on-device browser AI for grammar, tone, and translation (live app).
MIT © Shay Cojocaru