@shayc/open-board-format
v1.3.0
Published
A TypeScript toolkit for Open Board Format — the open standard for Augmentative and Alternative Communication (AAC) boards.
Maintainers
Readme
@shayc/open-board-format
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
Install
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.
Quick start
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"));Usage
Which function do I need?
- 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.
Read an OBZ package
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]));Create an OBZ package
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.
Validate with Zod directly
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);
}API
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.
OBF (single board)
| 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 |
OBZ (board package)
| 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 |
Format detection
| 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 |
Utilities
| 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 |
Types
| 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 |
Schemas
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";Errors
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.
Security
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.
Scope
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.
Versioning
Semver. The public API — every exported function, type, and Zod schema — is stable; breaking changes ship as major releases. See CHANGELOG.md.
Contributing
See CONTRIBUTING.md for development setup (Node 22+, Vitest, the changeset workflow).
Related
- 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).
License
MIT © Shay Cojocaru
