@effected/images
v0.8.0
Published
Image facts (format, MIME type, dimensions) from bytes, and a generated-image cache, for Effect.
Maintainers
Readme
@effected/images
Image facts from bytes, and a cache for generated images. ImageFacts reads the format, MIME type and pixel dimensions of a PNG, JPEG, GIF, WebP or AVIF file straight from its header, with one typed failure for bytes that are not a readable image. ImageCache answers "have I already generated an image from these parameters?", so a build stops re-running an expensive renderer such as an Open Graph card generator on every run.
Pre-
1.0.0. This package is part of the@effected/*kit, built on stable Effect v4 (effect~4.1.0) and still in0.xdevelopment. Stable Effect makes a kit1.0.0possible, not automatic. To keep youreffectand@effect/*versions on the line the kit is built and tested against, install@effected/pnpm-plugin-effected.Stability: unstable. This package's API surface is not yet considered complete and may change across
0.xreleases. Pin an exact version — even a package marked stable before1.0.0can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.
Install
npm install @effected/images effectpnpm add @effected/images effectRequires Node.js >=24.11.0. effect v4 is the only peer dependency, and the only dependency of any kind — the format readers are in-house.
All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED. Import from an ES module.
Image facts
The root entry is pure: no IO, no services. ImageFacts.fromBytesResult is the synchronous engine, usable from a script with no Effect runtime, and ImageFacts.fromBytes is the same read as an Effect.
import { ImageFacts } from "@effected/images";
import { Effect, Result } from "effect";
declare const bytes: Uint8Array;
// Synchronous: a Result, never a throw.
const result = ImageFacts.fromBytesResult(bytes);
if (Result.isSuccess(result)) {
const { format, mimeType, width, height } = result.success; // "png", "image/png", 1200, 630
const file = `card.${result.success.extension}`; // "card.png"; a JPEG is written ".jpg"
}
// Effect: fails with ImageParseError.
const facts = ImageFacts.fromBytes(bytes).pipe(
Effect.catchTag("ImageParseError", (e) => Effect.logWarning(`not an image: ${e.reason}`)),
);mimeType is one of the five media types (image/png, image/jpeg, image/gif, image/webp, image/avif), and extension is the conventional file extension: jpg for jpeg, the format name otherwise. extension is a getter derived from format, so it is not part of the encoded form, and a spread such as { ...facts } drops it.
ImageParseError.reason is unrecognized (no known signature), truncated (a recognized file cut short) or malformed (a header that contradicts itself). Readers never throw on any input, bounds-check every length before reading it, and bound the JPEG and AVIF walks with a step budget.
The generated-image cache
@effected/images/cache caches generated bytes under a key derived from the parameters that produced them. ImageCacheKey.fromParams encodes the parameters through your Schema, serializes them canonically and digests them with a salt — the identity of your generator, bumped when its template changes so every old entry misses.
import { ImageBackend, ImageCache, ImageCacheKey } from "@effected/images/cache";
import { NodeCrypto, NodeServices } from "@effect/platform-node";
import { Effect, Layer, Schema } from "effect";
const CardParams = Schema.Struct({ title: Schema.String, theme: Schema.String });
declare const renderCard: () => Effect.Effect<Uint8Array>; // your renderer
const CacheLive = ImageCache.layer.pipe(
Layer.provide(ImageBackend.layerDirectory({ directory: ".cache/og" })),
);
const program = Effect.gen(function* () {
const cache = yield* ImageCache;
const key = yield* ImageCacheKey.fromParams(
CardParams,
{ title: "Hello", theme: "dark" },
{ salt: "og-card-v3", namespace: "og" },
);
const { bytes, facts, hit } = yield* cache.getOrGenerate(
key,
renderCard, // () => Effect<Uint8Array, E, R> - runs only on a miss
{ accept: ["png"] },
);
return { bytes, width: facts.width, hit };
}).pipe(
Effect.provide(CacheLive),
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layerDigest)),
);getOrGenerate returns the bytes, their ImageFacts and whether it was a hit. With accept, the result's facts.format is typed as the accepted formats, so ["png", "jpeg", "webp"] gives "png" | "jpeg" | "webp"; without it, every format. A stored entry that no longer parses, or whose format is outside accept, is treated as a miss and overwritten. A generator result that is empty, unparseable or outside accept fails with ImageGenerateError and is never stored. Backend failures surface as ImageBackendError; the generator's own error passes through untouched.
A Promise-returning renderer
When the renderer returns a Promise, wrap it with both try and catch so its own error reaches the caller:
import { Effect } from "effect";
declare const render: (info: { readonly title: string }) => Promise<Uint8Array>;
declare const info: { readonly title: string };
const generate = () => Effect.tryPromise({ try: () => render(info), catch: (cause) => cause });The one-argument Effect.tryPromise(() => render(info)) wraps a rejection in UnknownError, so the generator's own error is lost.
Switching the cache off
ImageBackend.layerNone stores nothing: get always misses and set discards. Provide it when caching is disabled and keep the same getOrGenerate call; every call runs the generator and returns hit: false.
import { ImageBackend } from "@effected/images/cache";
declare const cachingEnabled: boolean;
const backend = cachingEnabled ? ImageBackend.layerDirectory({ directory: ".cache/og" }) : ImageBackend.layerNone;The key digest uses core Digest.Digest (effect/crypto), and the directory backend uses FileSystem and Path. On Node, NodeServices.layer from @effect/platform-node provides FileSystem and Path, and NodeCrypto.layerDigest provides Digest.Digest; Digest.layerWebCrypto from effect/crypto works on any runtime with WebCrypto.
Bring your own store
ImageBackend is a port, not a dependency. ImageBackend.layerFrom(key) adapts any service whose get and set match structurally — for example the Cache from @effected/store, with no edge between the two packages:
import { Cache } from "@effected/store";
import { ImageBackend } from "@effected/images/cache";
const StoreBacked = ImageBackend.layerFrom(Cache); // provide Cache underneathThe directory backend stores <directory>/<key>.<ext> through a temp file and an atomic rename. It accepts and ignores tags.
Features
ImageFacts— format, MIME type, file extension and pixel dimensions as stored in the file; syncfromBytesResultand EffectfromBytes.ImageExtension/ImageMimeType— the extension and media-type unions.ImageFormat— the literal unionpng | jpeg | gif | webp | avif.ImageParseError— the one parse failure, with areasonand, once a signature matched, theformat.ImageCacheKey/ImageCacheKeyError— content-addressed keys from schema-encoded parameters; failure reasonsencode,non-jsonanddigest.ImageBackend/ImageBackendError— the storage port, a directory backend, a no-store backend and a structural adapter; failure reasonsbackendandunsupported-content-type.ImageCache/ImageGenerateError—getOrGeneratewith an optionalacceptformat list that narrows the result's format.
SVG, BMP, ICO, TIFF, HEIC, JPEG XL, EXIF orientation, image transformation and cache eviction are out of scope.
