-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
How it is built, and why. Useful if you are reviewing it, extending it, or deciding whether to trust it.
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
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.
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.
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.
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.
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.
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.
-
No
node:imports undersrc/core/. The core is portable;io/is the only place that touches a filesystem. This is what makes a browser build possible later. -
No
anyin the emitted.d.ts.anyin 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.
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.
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.
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.
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.
Pure TypeScript image editing for Node and Bun. Core: 23.2 KB min+gzip, replacing a 7 MB ImageMagick bundle.
Using it
Working on it