Skip to content

Architecture

Abdulkader Safi edited this page Jul 16, 2026 · 1 revision

Architecture

How it is built, and why. Useful if you are reviewing it, extending it, or deciding whether to trust it.

The shape

src/
  index.ts              the barrel: everything public, nothing eager
  core/
    image.ts            RawImage, the one data structure
    errors.ts           the error classes and codes
    formats.ts          magic-byte sniffing
    dispatch.ts         decode/encode/probe/preload — the format table
    pipeline.ts         PipelineSpec, applySpec, validateSpec
    simg.ts             the SImg chain and Pipeline
    supported.ts        supportedFormats()
    codecs/             one file per format
    transform/          crop, flip, rotate, rotate90, resize, resample
  io/index.ts           fromFile/toFile — the ONLY node:fs import

RawImage: one data structure

interface RawImage { width: number; height: number; data: Uint8ClampedArray }

RGBA, non-premultiplied, row-major, top-left origin, 4 bytes per pixel. Every codec decodes to this and every transform takes and returns it, so a codec never knows about a transform and a transform never knows about a format.

Uint8ClampedArray rather than Uint8Array because it clamps on write. Resampling overshoots — lanczos3 has negative lobes and will produce −12 and 300 on a sharp edge — and the alternative is a Math.min(255, Math.max(0, x)) at every write site, one of which you will forget.

Non-premultiplied because it is what every codec reads and writes. Premultiplied is the right representation for compositing, so the resampler converts, works, and converts back — a decision made where it matters instead of imposed on the whole library.

The layers

Codecs know bytes and pixels. Nothing else. Each exports probe/decode/encode.

Transforms know pixels and geometry. No format ever reaches them.

Dispatch is the seam: sniff the format, guard the size, call the codec, apply EXIF orientation. One table:

const CODECS: Partial<Record<Format, Codec>> = {
  png:  { probe: probePng,  decode: decodePng,  encode: encodePng,  options: [] },
  jpeg: { probe: probeJpeg, decode: decodeJpeg, encode: encodeJpeg, options: ['quality', 'background'] },
  // ...
};

Adding a format is a row in that table plus a file in codecs/.

The chain and the pipeline are one implementation over a shared abstract base, so a chain method and a pipeline method cannot drift apart — they are the same method.

The canonical order

crop → rotate → flip → resize → format. Not the order you call the methods in.

The alternative — execute in call order — means a chain reads differently depending on how it was written, and a crop rectangle drawn on the source means something different after a resize. One order, decided once, documented, tested against hand-composed transforms.

That last part matters: the order test cannot compare a chain to another chain, because both go through the same executor and would agree on whatever the order happened to be. It pins the output against transforms composed by hand.

Async everywhere

Even a PNG resize, which needs no async work. WebP's WASM has to load sometime, and an API that is sometimes async is worse than one that always is: every caller writes the await anyway, and the one that forgot finds out on the WebP path in production.

No dependencies

Zero runtime dependencies besides the WebP WASM. Not a purity exercise — it is the whole premise. Every dependency is a transitive tree and a size surprise, and the artifact has to stay small enough for Obsidian Sync to carry.

node:zlib instead of a bundled inflate is the load-bearing example: pako is ~45 KB, a third of the entire budget, for something the runtime already has.

The two boundaries, enforced mechanically

npm run check:guards greps for these, and each guard has a test that watches it fail on a planted violation. A guard nobody has watched fail is a comment.

  1. No node: imports under src/core/. The core is portable; io/ is the only place that touches a filesystem. This is what makes a browser build possible later.
  2. No any in the emitted .d.ts. any in a declaration file is how a strict library quietly stops being one, and it is only visible in the build output — the source can be clean while an inferred return type widens.

The size budget

150 KB min+gzip for the core. Currently 23.2 KB. CI fails the build over budget — a budget nobody enforces is a wish.

"The core" is measured as the entry plus the transitive closure of its static imports: what a consumer loads before it can call anything. That is not pedantry. Measuring the entry file alone reported 23 KB and passed while the WebP WASM sat in a shared chunk the entry eagerly depended on, because esbuild hoists a statically-imported module rather than inlining it. The disaster was fully present and the gate was green.

WebP: the one lazy thing

libwebp compiled to WASM, base64'd into a generated module, reached through a dynamic import().

Base64 rather than a .wasm file beside the JS because an Obsidian plugin is a single esbuild'd main.js, and every other load path — fetch, new URL(..., import.meta.url), readFile — fails there for a different reason. Inlining is the only one that survives being bundled into somebody else's file.

Decode and encode are separate modules, so a plugin that only reads WebP never carries the encoder: 66 KB gzipped for the decoder, 168 KB for the encoder, 0 KB for anyone who touches neither.

A failed load is permanent and recorded, not retried per file.

Testing

node --test, no framework. Node 22 strips the types, so tests run straight from .ts with no build step in front of them.

Fixtures are generated by ImageMagick, not by this library's own encoder. Testing a decoder against your own encoder proves the two agree, which is not the same as either being right — they can be wrong together, symmetrically, forever.

Mutation testing is the primary quality tool, and it has found a real bug on essentially every branch. Tests that pass are not evidence; tests that fail when the code is broken are. Surviving mutants are either killed or proven equivalent and documented in place — a survivor nobody explained and a survivor that is fine look identical six months later.

The specs

features/ in the repo is one file per feature: what it does, the edge cases, the acceptance criteria, and the decisions that were considered and rejected. They are written before the code and updated when reality disagrees — including when the implementation turned out to be right and the spec wrong.

docs/superpowers/specs/ records the cross-cutting decisions: why HEIC was dropped, why the whole API is async.

Clone this wiki locally