@effected/images 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { EXTENSIONS, MIME_TYPES } from "./ImageFormat.js";
2
+ import { ImageBackendError } from "./ImageBackendError.js";
3
+ import { DIGEST_PATTERN } from "./internal/digest.js";
4
+ import { Context, Effect, FileSystem, Layer, Option, Path, Random } from "effect";
5
+
6
+ //#region src/ImageBackend.ts
7
+ /** `[extension, media type]` pairs, in the fixed order `get` probes them, from the package's one extension table. */
8
+ const FILES = Object.entries(EXTENSIONS).map(([format, ext]) => [ext, MIME_TYPES[format]]);
9
+ /** Media type to extension. Read only through `Object.hasOwn`: a content type is caller input. */
10
+ const EXTENSION_BY_TYPE = Object.fromEntries(FILES.map(([ext, type]) => [type, ext]));
11
+ const assertDigest = (key) => DIGEST_PATTERN.test(key) ? Effect.void : Effect.die(/* @__PURE__ */ new TypeError(`image backend key ${JSON.stringify(key)} is not a 64-char hex digest`));
12
+ /**
13
+ * The storage port behind `ImageCache`.
14
+ *
15
+ * @public
16
+ */
17
+ var ImageBackend = class ImageBackend extends Context.Service()("@effected/images/ImageBackend") {
18
+ /**
19
+ * The backend that stores nothing: `get` always misses and `set` discards the bytes.
20
+ *
21
+ * @remarks
22
+ * "The cache is off" as a layer, so a program keeps one `ImageCache.getOrGenerate` code path whether caching is
23
+ * enabled or not: provide this when it is off and `layerDirectory` (or `layerFrom`) when it is on. Every call runs
24
+ * the generator and returns `hit: false`; the generated bytes are still read and checked against `accept`. It needs
25
+ * nothing in `R`, never fails, and is a plain value, so it builds once however often it is provided.
26
+ */
27
+ static layerNone = Layer.succeed(this)({
28
+ get: () => Effect.succeedNone,
29
+ set: () => Effect.void
30
+ });
31
+ /**
32
+ * A backend over one directory: `<directory>/<key>.<ext>`.
33
+ *
34
+ * @remarks
35
+ * Writes go to a temp file in the same directory and are renamed into place, so a failed or interrupted write
36
+ * never leaves a file that reads as a hit. Tags are accepted and ignored — a directory has no tag index. Keys must
37
+ * be 64-char lowercase hex digests (as `ImageCacheKey` produces); anything else is a defect, so a key can never
38
+ * name a path outside the directory. No expiry or eviction.
39
+ *
40
+ * Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
41
+ */
42
+ static layerDirectory(options) {
43
+ return Layer.effect(ImageBackend, Effect.gen(function* () {
44
+ const fs = yield* FileSystem.FileSystem;
45
+ const path = yield* Path.Path;
46
+ const fileFor = (key, ext) => path.join(options.directory, `${key}.${ext}`);
47
+ return {
48
+ get: Effect.fn("ImageBackend.get")(function* (key) {
49
+ yield* assertDigest(key);
50
+ for (const [ext, contentType] of FILES) {
51
+ const bytes = yield* fs.readFile(fileFor(key, ext)).pipe(Effect.map(Option.some), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeedNone), Effect.mapError((cause) => new ImageBackendError({
52
+ operation: "get",
53
+ key,
54
+ cause
55
+ })));
56
+ if (Option.isSome(bytes)) return Option.some({
57
+ value: bytes.value,
58
+ contentType
59
+ });
60
+ }
61
+ return Option.none();
62
+ }),
63
+ set: Effect.fn("ImageBackend.set")(function* (params) {
64
+ yield* assertDigest(params.key);
65
+ const ext = params.contentType !== void 0 && Object.hasOwn(EXTENSION_BY_TYPE, params.contentType) ? EXTENSION_BY_TYPE[params.contentType] : void 0;
66
+ if (ext === void 0) return yield* new ImageBackendError({
67
+ operation: "set",
68
+ key: params.key,
69
+ cause: /* @__PURE__ */ new Error(`unsupported content type ${JSON.stringify(params.contentType ?? null)}`)
70
+ });
71
+ const target = fileFor(params.key, ext);
72
+ const suffix = yield* Random.nextIntBetween(0, 2147483647);
73
+ const temp = path.join(options.directory, `.${params.key}.${suffix.toString(36)}.tmp`);
74
+ yield* Effect.gen(function* () {
75
+ yield* fs.makeDirectory(options.directory, { recursive: true });
76
+ yield* fs.writeFile(temp, params.value);
77
+ for (const [other] of FILES) if (other !== ext) yield* fs.remove(fileFor(params.key, other), { force: true });
78
+ yield* fs.rename(temp, target);
79
+ }).pipe(Effect.onError(() => fs.remove(temp, { force: true }).pipe(Effect.ignore)), Effect.mapError((cause) => new ImageBackendError({
80
+ operation: "set",
81
+ key: params.key,
82
+ cause
83
+ })));
84
+ })
85
+ };
86
+ }));
87
+ }
88
+ /**
89
+ * Adapt any structurally matching service, `@effected/store`'s `Cache` being the intended one, as the backend.
90
+ *
91
+ * @remarks
92
+ * `ImageBackend.layerFrom(Cache)` gives the image cache store's TTL, eviction and tag invalidation. Every source
93
+ * failure is wrapped as {@link ImageBackendError} with the original as `cause`.
94
+ *
95
+ * Each call mints a fresh layer; bind the result to a `const` or the backend builds twice.
96
+ */
97
+ static layerFrom(key) {
98
+ return Layer.effect(ImageBackend, Effect.map(key, (source) => ({
99
+ get: (k) => source.get(k).pipe(Effect.map(Option.map((entry) => ({
100
+ value: entry.value,
101
+ contentType: entry.contentType
102
+ }))), Effect.mapError((cause) => new ImageBackendError({
103
+ operation: "get",
104
+ key: k,
105
+ cause
106
+ })), Effect.withSpan("ImageBackend.get")),
107
+ set: (params) => source.set(params).pipe(Effect.mapError((cause) => new ImageBackendError({
108
+ operation: "set",
109
+ key: params.key,
110
+ cause
111
+ })), Effect.withSpan("ImageBackend.set"))
112
+ })));
113
+ }
114
+ };
115
+
116
+ //#endregion
117
+ export { ImageBackend };
@@ -0,0 +1,28 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/ImageBackendError.ts
4
+ const causeMessage = (cause) => {
5
+ let current = cause;
6
+ for (let depth = 0; depth < 8 && current instanceof Error && current.cause instanceof Error; depth++) current = current.cause;
7
+ return current instanceof Error ? current.message : String(current);
8
+ };
9
+ /**
10
+ * An image backend read or write failed.
11
+ *
12
+ * @public
13
+ */
14
+ var ImageBackendError = class extends Schema.TaggedError()("ImageBackendError", {
15
+ /** The backend operation that failed. */
16
+ operation: Schema.Literals(["get", "set"]),
17
+ /** The key involved. */
18
+ key: Schema.String,
19
+ /** The underlying failure, preserved structurally. */
20
+ cause: Schema.Defect()
21
+ }) {
22
+ get message() {
23
+ return `Image backend ${this.operation} failed for key "${this.key}": ${causeMessage(this.cause)}`;
24
+ }
25
+ };
26
+
27
+ //#endregion
28
+ export { ImageBackendError };
package/ImageCache.js ADDED
@@ -0,0 +1,70 @@
1
+ import { ImageFacts } from "./ImageFacts.js";
2
+ import { ImageBackend } from "./ImageBackend.js";
3
+ import { ImageGenerateError } from "./ImageGenerateError.js";
4
+ import { Context, Effect, Layer, Option, Result } from "effect";
5
+
6
+ //#region src/ImageCache.ts
7
+ /**
8
+ * True when `accept` admits the facts' format. With `accept` omitted the call's `F` is every format, so admitting
9
+ * everything is exactly the narrowing the guard claims.
10
+ */
11
+ const accepted = (facts, accept) => accept === void 0 || accept.includes(facts.format);
12
+ /**
13
+ * A cache for generated images, keyed by `ImageCacheKey`, stored through {@link ImageBackend}.
14
+ *
15
+ * @public
16
+ */
17
+ var ImageCache = class ImageCache extends Context.Service()("@effected/images/ImageCache") {
18
+ /** The cache over whichever {@link ImageBackend} the app provides. */
19
+ static layer = Layer.effect(ImageCache, Effect.gen(function* () {
20
+ const backend = yield* ImageBackend;
21
+ const getOrGenerate = (key, generate, options) => Effect.gen(function* () {
22
+ const accept = options?.accept;
23
+ const stored = yield* backend.get(key.digest);
24
+ if (Option.isSome(stored)) {
25
+ const facts = ImageFacts.fromBytesResult(stored.value.value);
26
+ if (Result.isSuccess(facts) && accepted(facts.success, accept)) {
27
+ yield* Effect.annotateCurrentSpan({
28
+ hit: true,
29
+ format: facts.success.format
30
+ });
31
+ return {
32
+ bytes: stored.value.value,
33
+ facts: facts.success,
34
+ hit: true
35
+ };
36
+ }
37
+ }
38
+ yield* Effect.annotateCurrentSpan("hit", false);
39
+ const bytes = yield* generate();
40
+ if (bytes.byteLength === 0) return yield* new ImageGenerateError({ reason: "empty" });
41
+ const parsed = ImageFacts.fromBytesResult(bytes);
42
+ if (Result.isFailure(parsed)) return yield* new ImageGenerateError({
43
+ reason: "unparseable",
44
+ cause: parsed.failure
45
+ });
46
+ const facts = parsed.success;
47
+ yield* Effect.annotateCurrentSpan("format", facts.format);
48
+ if (!accepted(facts, accept)) return yield* new ImageGenerateError({
49
+ reason: "rejected-format",
50
+ format: facts.format,
51
+ accept: [...accept ?? []]
52
+ });
53
+ yield* backend.set({
54
+ key: key.digest,
55
+ value: bytes,
56
+ contentType: facts.mimeType,
57
+ tags: [key.namespace]
58
+ });
59
+ return {
60
+ bytes,
61
+ facts,
62
+ hit: false
63
+ };
64
+ }).pipe(Effect.withSpan("ImageCache.getOrGenerate", { attributes: { namespace: key.namespace } }));
65
+ return { getOrGenerate };
66
+ }));
67
+ };
68
+
69
+ //#endregion
70
+ export { ImageCache };
@@ -0,0 +1,56 @@
1
+ import { DIGEST_PATTERN } from "./internal/digest.js";
2
+ import { ImageCacheKeyError } from "./ImageCacheKeyError.js";
3
+ import { canonicalJson } from "./internal/canonical.js";
4
+ import { Crypto, Effect, Result, Schema } from "effect";
5
+ import * as Hex from "effect/encoding/Hex";
6
+
7
+ //#region src/ImageCacheKey.ts
8
+ /**
9
+ * The identity of one generated image: a SHA-256 digest of the salt and the schema-encoded parameters.
10
+ *
11
+ * @public
12
+ */
13
+ var ImageCacheKey = class ImageCacheKey extends Schema.Class("ImageCacheKey")({
14
+ /** Lowercase hex SHA-256 of `salt`, a NUL, and the canonical JSON of the encoded params. */
15
+ digest: Schema.String.check(Schema.isPattern(DIGEST_PATTERN)),
16
+ /** The salt the digest was derived with. */
17
+ salt: Schema.String,
18
+ /** The grouping tag. */
19
+ namespace: Schema.String
20
+ }) {
21
+ /**
22
+ * Derive a key.
23
+ *
24
+ * @remarks
25
+ * `params` are encoded through `schema`, so a transformation participates and the key reflects the wire form. The
26
+ * digest is core `Crypto`'s SHA-256, so `Crypto` is required — Node apps get it from `NodeServices.layer` or
27
+ * `NodeCrypto.layer`.
28
+ */
29
+ static fromParams(schema, params, options) {
30
+ return Effect.gen(function* () {
31
+ const encoded = yield* Schema.encodeEffect(schema)(params).pipe(Effect.mapError((cause) => new ImageCacheKeyError({
32
+ reason: "encode",
33
+ detail: cause.message,
34
+ cause
35
+ })));
36
+ const canonical = canonicalJson(encoded);
37
+ if (Result.isFailure(canonical)) return yield* new ImageCacheKeyError({
38
+ reason: "non-json",
39
+ detail: canonical.failure
40
+ });
41
+ const bytes = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(`${options.salt}\u0000${canonical.success}`)).pipe(Effect.mapError((cause) => new ImageCacheKeyError({
42
+ reason: "digest",
43
+ detail: cause.message,
44
+ cause
45
+ })));
46
+ return ImageCacheKey.make({
47
+ digest: Hex.encode(bytes),
48
+ salt: options.salt,
49
+ namespace: options.namespace
50
+ });
51
+ }).pipe(Effect.withSpan("ImageCacheKey.fromParams", { attributes: { namespace: options.namespace } }));
52
+ }
53
+ };
54
+
55
+ //#endregion
56
+ export { ImageCacheKey };
@@ -0,0 +1,31 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/ImageCacheKeyError.ts
4
+ /**
5
+ * Cache-key parameters that could not be turned into a key.
6
+ *
7
+ * @remarks
8
+ * `encode`: the parameter schema rejected the params. `non-json`: the encoded form holds a value canonical JSON
9
+ * cannot carry (a `Date`, a `bigint`, a non-finite number, a class instance). `digest`: the `Crypto` service failed.
10
+ *
11
+ * @public
12
+ */
13
+ var ImageCacheKeyError = class extends Schema.TaggedError()("ImageCacheKeyError", {
14
+ /** Which step failed. */
15
+ reason: Schema.Literals([
16
+ "encode",
17
+ "non-json",
18
+ "digest"
19
+ ]),
20
+ /** What exactly was wrong. */
21
+ detail: Schema.String,
22
+ /** The schema error (`encode`) or platform error (`digest`). */
23
+ cause: Schema.optionalKey(Schema.Defect())
24
+ }) {
25
+ get message() {
26
+ return `Cannot derive an image cache key (${this.reason}): ${this.detail}`;
27
+ }
28
+ };
29
+
30
+ //#endregion
31
+ export { ImageCacheKeyError };
package/ImageFacts.js ADDED
@@ -0,0 +1,78 @@
1
+ import { EXTENSIONS, ImageFormat, ImageMimeTypeSchema, MIME_TYPES } from "./ImageFormat.js";
2
+ import { ImageParseError } from "./ImageParseError.js";
3
+ import { readAvif } from "./internal/avif.js";
4
+ import { detectFormat } from "./internal/detect.js";
5
+ import { readGif } from "./internal/gif.js";
6
+ import { readJpeg } from "./internal/jpeg.js";
7
+ import { readPng } from "./internal/png.js";
8
+ import { readWebp } from "./internal/webp.js";
9
+ import { Effect, Result, Schema } from "effect";
10
+
11
+ //#region src/ImageFacts.ts
12
+ const READERS = {
13
+ png: readPng,
14
+ jpeg: readJpeg,
15
+ gif: readGif,
16
+ webp: readWebp,
17
+ avif: readAvif
18
+ };
19
+ const Dimension = Schema.Int.check(Schema.isGreaterThan(0));
20
+ /**
21
+ * What an image's header says about it: format, MIME type and stored pixel dimensions.
22
+ *
23
+ * @remarks
24
+ * Dimensions are as stored in the file; EXIF orientation is not applied.
25
+ *
26
+ * @public
27
+ */
28
+ var ImageFacts = class ImageFacts extends Schema.Class("ImageFacts")({
29
+ /** The detected format. */
30
+ format: ImageFormat,
31
+ /** The IANA media type for `format`: one of `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`. */
32
+ mimeType: ImageMimeTypeSchema,
33
+ /** Stored pixel width. */
34
+ width: Dimension,
35
+ /** Stored pixel height. */
36
+ height: Dimension
37
+ }) {
38
+ /**
39
+ * The conventional file extension for `format`, without the dot: `jpg` for `jpeg`, the format name otherwise.
40
+ *
41
+ * @remarks
42
+ * A getter, not a field: it is derived from `format` on read, so it is absent from the encoded form and a decoded
43
+ * value has it again. Name a written file with it instead of keeping a private format-to-extension table.
44
+ */
45
+ get extension() {
46
+ return EXTENSIONS[this.format];
47
+ }
48
+ /**
49
+ * Read facts from an image's bytes — the synchronous primitive.
50
+ *
51
+ * @remarks
52
+ * Reads only the header. Never throws: unknown bytes are `unrecognized`, a
53
+ * header that ends early is `truncated`, an impossible header is `malformed`.
54
+ */
55
+ static fromBytesResult(bytes) {
56
+ const format = detectFormat(bytes);
57
+ if (format === void 0) return Result.fail(new ImageParseError({ reason: "unrecognized" }));
58
+ const read = READERS[format](bytes);
59
+ if (Result.isFailure(read)) return Result.fail(new ImageParseError({
60
+ reason: read.failure.reason,
61
+ format,
62
+ detail: read.failure.detail
63
+ }));
64
+ return Result.succeed(ImageFacts.make({
65
+ format,
66
+ mimeType: MIME_TYPES[format],
67
+ width: read.success.width,
68
+ height: read.success.height
69
+ }));
70
+ }
71
+ /**
72
+ * Read facts from an image's bytes — {@link ImageFacts.fromBytesResult} with a span.
73
+ */
74
+ static fromBytes = Effect.fn("ImageFacts.fromBytes")((bytes) => Effect.fromResult(ImageFacts.fromBytesResult(bytes)));
75
+ };
76
+
77
+ //#endregion
78
+ export { ImageFacts };
package/ImageFormat.js ADDED
@@ -0,0 +1,55 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/ImageFormat.ts
4
+ /**
5
+ * The image formats `@effected/images` reads.
6
+ *
7
+ * @public
8
+ */
9
+ const ImageFormat = Schema.Literals([
10
+ "png",
11
+ "jpeg",
12
+ "gif",
13
+ "webp",
14
+ "avif"
15
+ ]);
16
+ /**
17
+ * The conventional file extension for each format — the one table every extension in the package is read from.
18
+ * `ImageFacts#extension` declares the union these values must fall in.
19
+ *
20
+ * @internal
21
+ */
22
+ const EXTENSIONS = {
23
+ png: "png",
24
+ jpeg: "jpg",
25
+ gif: "gif",
26
+ webp: "webp",
27
+ avif: "avif"
28
+ };
29
+ /**
30
+ * The IANA media type for each format. `ImageFacts.mimeType`'s schema is built from these values.
31
+ *
32
+ * @internal
33
+ */
34
+ const MIME_TYPES = {
35
+ png: "image/png",
36
+ jpeg: "image/jpeg",
37
+ gif: "image/gif",
38
+ webp: "image/webp",
39
+ avif: "image/avif"
40
+ };
41
+ /**
42
+ * The schema of `ImageFacts.mimeType`: the literals of {@link MIME_TYPES}, so the two cannot drift.
43
+ *
44
+ * @internal
45
+ */
46
+ const ImageMimeTypeSchema = Schema.Literals([
47
+ MIME_TYPES.png,
48
+ MIME_TYPES.jpeg,
49
+ MIME_TYPES.gif,
50
+ MIME_TYPES.webp,
51
+ MIME_TYPES.avif
52
+ ]);
53
+
54
+ //#endregion
55
+ export { EXTENSIONS, ImageFormat, ImageMimeTypeSchema, MIME_TYPES };
@@ -0,0 +1,34 @@
1
+ import { ImageFormat } from "./ImageFormat.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/ImageGenerateError.ts
5
+ /**
6
+ * A generator produced bytes the cache will not store.
7
+ *
8
+ * @remarks
9
+ * `empty`: zero bytes. `unparseable`: not a readable image (`cause` is the `ImageParseError`).
10
+ * `rejected-format`: a readable image whose format is outside the caller's `accept` list.
11
+ *
12
+ * @public
13
+ */
14
+ var ImageGenerateError = class extends Schema.TaggedError()("ImageGenerateError", {
15
+ /** Why the output was refused. */
16
+ reason: Schema.Literals([
17
+ "empty",
18
+ "unparseable",
19
+ "rejected-format"
20
+ ]),
21
+ /** The output's format, when it was readable. */
22
+ format: Schema.optionalKey(ImageFormat),
23
+ /** The formats the caller accepts, for `rejected-format`. */
24
+ accept: Schema.optionalKey(Schema.Array(ImageFormat)),
25
+ /** The parse failure, for `unparseable`. */
26
+ cause: Schema.optionalKey(Schema.Defect())
27
+ }) {
28
+ get message() {
29
+ return this.reason === "empty" ? "Image generator returned no bytes" : this.reason === "unparseable" ? "Image generator returned bytes that are not a readable image" : `Image generator returned a ${this.format ?? "?"} image; accepted: ${(this.accept ?? []).join(", ")}`;
30
+ }
31
+ };
32
+
33
+ //#endregion
34
+ export { ImageGenerateError };
@@ -0,0 +1,35 @@
1
+ import { ImageFormat } from "./ImageFormat.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/ImageParseError.ts
5
+ /**
6
+ * Bytes that could not be read as an image header.
7
+ *
8
+ * @remarks
9
+ * `unrecognized`: no known signature. `truncated`: a signature matched but the
10
+ * bytes end before the dimension fields. `malformed`: a header field is
11
+ * impossible (a zero or out-of-range dimension, an inconsistent length, or a
12
+ * walk that exhausted its step budget).
13
+ *
14
+ * @public
15
+ */
16
+ var ImageParseError = class extends Schema.TaggedError()("ImageParseError", {
17
+ /** Why the header could not be read. */
18
+ reason: Schema.Literals([
19
+ "unrecognized",
20
+ "truncated",
21
+ "malformed"
22
+ ]),
23
+ /** The format whose signature matched, when one did. */
24
+ format: Schema.optionalKey(ImageFormat),
25
+ /** What exactly was wrong. */
26
+ detail: Schema.optionalKey(Schema.String)
27
+ }) {
28
+ get message() {
29
+ const lead = this.reason === "unrecognized" ? "Unrecognized image signature" : `${this.reason === "truncated" ? "Truncated" : "Malformed"} ${this.format ?? "image"} header`;
30
+ return this.detail === void 0 ? lead : `${lead}: ${this.detail}`;
31
+ }
32
+ };
33
+
34
+ //#endregion
35
+ export { ImageParseError };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.