-
Notifications
You must be signed in to change notification settings - Fork 0
Errors
Every failure is an SImgError with a code. Never a raw TypeError from three frames deep
inside a decoder, never a bare string, never undefined where an image should be.
import { SImgError, UnsupportedFormatError, ImageTooLargeError, CorruptImageError } from 'safi-image';
try {
await SImg.fromBuffer(bytes).toFormat('webp').toBuffer();
} catch (e) {
if (e instanceof UnsupportedFormatError) {
// not an image we read
} else if (e instanceof ImageTooLargeError) {
// a decompression bomb, or a genuinely enormous photo
} else if (e instanceof SImgError) {
switch (e.code) { /* ... */ }
}
}They are classes rather than just codes because a consumer's catch block is the whole point:
instanceof narrows, and the subclasses all narrow to SImgError.
| Code | Class | Means |
|---|---|---|
UNSUPPORTED_FORMAT |
UnsupportedFormatError |
The magic bytes matched nothing we read |
CORRUPT_IMAGE |
CorruptImageError |
The magic bytes matched; the rest did not parse |
FORMAT_MISMATCH |
SImgError |
The header says PNG and the caller said JPEG. We trust the header |
INVALID_OPTION |
InvalidOptionError |
A crop outside the image, a resize to 0, a quality of 500 |
IMAGE_TOO_LARGE |
ImageTooLargeError |
Declared dimensions exceed the decode cap |
CODEC_LOAD_FAILED |
SImgError |
A lazily-loaded codec (WebP) could not load |
ENCODE_FAILED |
SImgError |
The encoder itself blew up |
e.code is typed as SImgErrorCode, so a switch over it is exhaustive-checkable.
Not for whoever wrote the throw:
jpeg.quality must be an integer from 1 to 100, got 500
pipeline.resize.maxLongEdge must be a number, got "banana"
Not a supported image format. First bytes: 00 01 02 03
They say what was wanted, what arrived, and — where there is a list — what the options are.
UnsupportedFormatError quotes the first bytes, so a bug report tells you what the file
actually was rather than what its extension claimed.
InvalidOptionError also carries .option and .value if you want to build your own
message.
Almost always a progressive JPEG. They are not supported yet, and they are common — this is a known gap, and it fails loudly on purpose rather than producing garbage.
The decompression-bomb guard: a 100×100 PNG can declare itself 60,000×60,000 and cost you 14 GB. The cap is 134M pixels (~512 MB of RGBA), checked from the header, before allocating anything.
If you meant it, raise it per call:
await decode(bytes, { maxPixels: 500_000_000 });A settings file someone hand-edited, or one written by an older version. SImg.pipeline(spec)
validates at construction, so it throws while the caller still has the file open rather than
three steps later inside a transform.
WebP's WASM did not load. It is permanent: WebP moves to unavailable and is not retried.
A codec that failed to load will fail again, and retrying once per image across a batch of 500
turns one error into 500 slow ones.
supportedFormats().unavailable; // ['webp'] — offer the user something elseCheck supportedFormats() rather than catching this per file.
fromFile on a missing path throws Node's ENOENT, unwrapped. Deliberate: a missing file is
not an image error, and Node's message is better than anything written over the top of it.
Every option is checked at runtime, even the ones the types cover. Two reasons, both real:
// 1. A settings file. No types anywhere near this.
const config = JSON.parse('{"quality": null}');
// 2. TypeScript's own holes. Excess property checking only fires on literals:
const options = { quality: 80 };
encode(img, 'png', options); // compiles. Throws at runtime, correctly.The types are for the developer at 3pm. The validation is for production at 3am.
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