/**
* ImageCacheRuntime — Effect v4-owned manifest, cache files, cleanup, and subprocess I/O.
*
* Sync entry rendering and display memo stay in `index.ts`; this service owns
* session cache lifecycle, concurrent-safe state, and deterministic shutdown.
*
* Error semantics: `ImageCacheRuntimeClosedError` is lifecycle closure only.
* Filesystem/subprocess failures use `ImageCacheIoError` with an operation label.
*/
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { basename, join } from "node:path";
import { promisify } from "node:util";
import {
convertToPng,
formatDimensionNote,
getAgentDir,
resizeImage,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent } from "@earendil-works/pi-ai";
import {
Cause,
Context,
Data,
Effect,
Exit,
Fiber,
FiberSet,
Layer,
ManagedRuntime,
MutableRef,
Result,
SynchronizedRef,
} from "effect";
import {
CACHE_TTL_MS,
detectMimeType,
displayPathFor,
fileStem,
findByHash,
formatPlaceholder,
hashBytes,
imageExtension,
isInsideTmpDir,
MAX_INLINE_BYTES,
MAX_SOURCE_BYTES,
MODEL_SUPPORTED_MIMES,
normalizeMimeType,
previewEntryData,
} from "../lib/helpers.ts";
import type {
CachedImage,
ClipboardImages,
ClipboardScriptResult,
Manifest,
PreviewEntryData,
} from "../lib/types.ts";
const execFileAsync = promisify(execFile);
const CACHE_ROOT = join(getAgentDir(), "cache", "image-cache");
function parseManifest(raw: string): Manifest {
try {
return JSON.parse(raw) as Manifest;
} catch {
return { version: 1, images: [] };
}
}
export class ImageCacheRuntimeClosedError extends Data.TaggedError("ImageCacheRuntimeClosedError")<{
readonly message: string;
}> {}
export class ImageCacheIoError extends Data.TaggedError("ImageCacheIoError")<{
readonly operation: string;
readonly path?: string;
readonly message: string;
readonly cause: unknown;
}> {}
export type ImageCacheRuntimeError = ImageCacheRuntimeClosedError | ImageCacheIoError;
function ioError(operation: string, cause: unknown, path?: string): ImageCacheIoError {
return new ImageCacheIoError({
operation,
...(path ? { path } : {}),
message: cause instanceof Error ? cause.message : String(cause),
cause,
});
}
function tryIo(
operation: string,
tryFn: () => Promise,
path?: string,
): Effect.Effect {
return Effect.tryPromise({
try: tryFn,
catch: (cause) => ioError(operation, cause, path),
});
}
/** Best-effort IO: failures are absorbed (cleanup, touch, missing manifest, clipboard). */
function tryIoBestEffort(
operation: string,
tryFn: () => Promise,
path?: string,
): Effect.Effect {
return tryIo(operation, tryFn, path).pipe(Effect.orElseSucceed(() => undefined));
}
/** Per-runtime test controls injected at construction time. */
export interface ImageCachePostWriteTestGate {
readonly onImageFileWritten: () => void;
readonly waitUntilReleased: () => Promise;
}
export interface ImageCacheRuntimeTestControls {
readonly mutationDelayMs?: number;
readonly postImageWriteDelayMs?: number;
readonly postWriteGate?: ImageCachePostWriteTestGate;
}
export interface ImageCacheRuntimeOptions {
readonly testControls?: ImageCacheRuntimeTestControls;
}
interface ResolvedImageCacheRuntimeConfig {
readonly mutationDelayMs: number;
readonly postImageWriteDelayMs: number;
readonly postWriteGate?: ImageCachePostWriteTestGate;
}
const defaultRuntimeConfig: ResolvedImageCacheRuntimeConfig = {
mutationDelayMs: 0,
postImageWriteDelayMs: 0,
};
function resolveRuntimeConfig(options?: ImageCacheRuntimeOptions): ResolvedImageCacheRuntimeConfig {
return {
mutationDelayMs: options?.testControls?.mutationDelayMs ?? 0,
postImageWriteDelayMs: options?.testControls?.postImageWriteDelayMs ?? 0,
postWriteGate: options?.testControls?.postWriteGate,
};
}
/** Synchronous post-write barrier for deterministic orphan-window tests. */
export function createImageFileWrittenGate(): {
readonly testControls: ImageCacheRuntimeTestControls;
readonly waitForImageFileWritten: () => Promise;
readonly release: () => void;
} {
let notifyWritten!: () => void;
let releaseBarrier!: () => void;
const writtenPromise = new Promise((resolve) => {
notifyWritten = resolve;
});
const releasePromise = new Promise((resolve) => {
releaseBarrier = resolve;
});
return {
testControls: {
postWriteGate: {
onImageFileWritten: () => notifyWritten(),
waitUntilReleased: () => releasePromise,
},
},
waitForImageFileWritten: () => writtenPromise,
release: () => releaseBarrier(),
};
}
interface ImageCacheState {
cacheDir: string;
manifestPath: string;
nextImageId: number;
imagesByPlaceholder: Map;
}
function sessionState(sessionId: string): ImageCacheState {
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
const cacheDir = join(CACHE_ROOT, safeSessionId);
return {
cacheDir,
manifestPath: join(cacheDir, "manifest.json"),
nextImageId: 1,
imagesByPlaceholder: new Map(),
};
}
export interface ImageCacheRuntimeShape {
readonly init: (sessionId: string) => Effect.Effect;
readonly imageCount: Effect.Effect;
readonly getImage: (
placeholder: string,
) => Effect.Effect;
readonly listImages: Effect.Effect;
readonly previewData: (cached: CachedImage) => PreviewEntryData;
readonly cacheBytes: (
bytes: Buffer,
detectedMime: string,
sourcePath?: string,
) => Effect.Effect;
readonly cacheImageFile: (
filePath: string,
sourcePath?: string,
) => Effect.Effect;
readonly cacheExistingImage: (
sourcePath: string,
) => Effect.Effect;
readonly readMacClipboardImages: Effect.Effect;
readonly readMacClipboardText: Effect.Effect;
readonly toImageContent: (
cached: CachedImage,
) => Effect.Effect<
{ content: ImageContent; note?: string } | { error: string },
ImageCacheRuntimeError
>;
readonly touchCacheDir: Effect.Effect;
readonly clear: Effect.Effect;
readonly close: Effect.Effect;
}
export class ImageCacheRuntime extends Context.Service()(
"pi-tian-image-cache/ImageCacheRuntime",
) {}
const makeImageCacheRuntime = (config: ResolvedImageCacheRuntimeConfig) =>
Effect.gen(function* () {
const ref = yield* SynchronizedRef.make(sessionState(`process-${process.pid}`));
const workers = yield* FiberSet.make();
const inFlight = MutableRef.make(0);
const closed = MutableRef.make(false);
const closedError = () =>
new ImageCacheRuntimeClosedError({
message: "Image cache runtime is shut down; no further cache operations are accepted.",
});
const ensureOpen: Effect.Effect = Effect.suspend(() =>
MutableRef.get(closed) ? Effect.fail(closedError()) : Effect.void,
);
const rejectIfClosed: Effect.Effect = Effect.suspend(() =>
MutableRef.get(closed) ? Effect.fail(closedError()) : Effect.void,
);
const decrementInFlight = Effect.sync(() => {
MutableRef.set(inFlight, Math.max(0, MutableRef.get(inFlight) - 1));
});
/** Open-check and increment happen in one synchronous step (no yield between them). */
const acquireInFlight: Effect.Effect = Effect.suspend(() => {
if (MutableRef.get(closed)) return Effect.fail(closedError());
MutableRef.set(inFlight, MutableRef.get(inFlight) + 1);
return Effect.void;
});
const waitForDrain = (): Effect.Effect =>
Effect.gen(function* () {
while (MutableRef.get(inFlight) > 0) {
yield* Effect.sleep(5);
}
});
const runTracked = (
effect: Effect.Effect,
): Effect.Effect =>
acquireInFlight.pipe(
Effect.flatMap(() =>
Effect.gen(function* () {
const fiber = yield* FiberSet.run(workers, effect);
return yield* Fiber.join(fiber);
}),
),
Effect.ensuring(decrementInFlight),
);
const writeManifest = (state: ImageCacheState) =>
tryIo("writeManifest", async () => {
const { mkdir, rename, writeFile } = await import("node:fs/promises");
await mkdir(state.cacheDir, { recursive: true });
const manifest: Manifest = {
version: 1,
images: [...state.imagesByPlaceholder.values()],
};
const tmpPath = `${state.manifestPath}.${process.pid}.tmp`;
await writeFile(tmpPath, JSON.stringify(manifest, null, 2), "utf8");
await rename(tmpPath, state.manifestPath);
}, state.manifestPath);
const toPngBytes = (bytes: Buffer, mimeType: string, stagePath: string) =>
tryIo("convertToPng", async () => {
const converted = await convertToPng(bytes.toString("base64"), mimeType).catch(() => null);
if (converted) return Buffer.from(converted.data, "base64");
if (process.platform !== "darwin") return null;
const outPath = `${stagePath}.png`;
const { readFile, unlink, writeFile } = await import("node:fs/promises");
try {
await writeFile(stagePath, bytes);
await execFileAsync("/usr/bin/sips", ["-s", "format", "png", stagePath, "--out", outPath], {
timeout: 10_000,
maxBuffer: 2 * 1024 * 1024,
});
return await readFile(outPath);
} catch {
return null;
} finally {
await unlink(stagePath).catch(() => undefined);
await unlink(outPath).catch(() => undefined);
}
}, stagePath);
const cacheBytesLocked = (
state: ImageCacheState,
bytes: Buffer,
detectedMime: string,
sourcePath?: string,
): Effect.Effect => {
const orphanPaths: string[] = [];
let committed = false;
const rollbackOrphans = Effect.gen(function* () {
if (committed) return;
const { unlink } = yield* Effect.promise(() => import("node:fs/promises"));
for (const orphanPath of orphanPaths) {
yield* tryIoBestEffort("unlink", () => unlink(orphanPath), orphanPath);
}
});
return Effect.gen(function* () {
if (config.mutationDelayMs > 0) yield* Effect.sleep(config.mutationDelayMs);
yield* rejectIfClosed;
const sourceHash = hashBytes(bytes);
const existing = findByHash(state.imagesByPlaceholder.values(), sourceHash);
if (existing) return [state, existing] as const;
const { mkdir, writeFile } = yield* Effect.promise(() => import("node:fs/promises"));
yield* tryIo("mkdir", () => mkdir(state.cacheDir, { recursive: true }), state.cacheDir);
const id = state.nextImageId;
const placeholder = formatPlaceholder(id);
let mimeType = normalizeMimeType(detectedMime);
let storedBytes = bytes;
if (!MODEL_SUPPORTED_MIMES.has(mimeType)) {
const stagePath = join(state.cacheDir, `${fileStem(placeholder)}.source`);
const png = yield* toPngBytes(bytes, mimeType, stagePath);
if (!png) return [state, null] as const;
storedBytes = png;
mimeType = "image/png";
}
const filePath = join(state.cacheDir, `${fileStem(placeholder)}.${imageExtension(mimeType)}`);
yield* rejectIfClosed;
yield* tryIo("writeFile", () => writeFile(filePath, storedBytes), filePath);
orphanPaths.push(filePath);
if (mimeType !== "image/png") {
const converted = yield* tryIo(
"convertDisplayPng",
() => convertToPng(storedBytes.toString("base64"), mimeType).catch(() => null),
).pipe(Effect.orElseSucceed(() => null));
if (converted) {
const displayPath = displayPathFor(filePath);
yield* tryIoBestEffort(
"writeDisplayPng",
() => writeFile(displayPath, Buffer.from(converted.data, "base64")),
displayPath,
);
orphanPaths.push(displayPath);
}
}
if (config.postWriteGate) {
config.postWriteGate.onImageFileWritten();
yield* Effect.tryPromise({
try: () => config.postWriteGate!.waitUntilReleased(),
catch: (cause) =>
new ImageCacheRuntimeClosedError({
message: cause instanceof Error ? cause.message : String(cause),
}),
});
}
if (config.postImageWriteDelayMs > 0) yield* Effect.sleep(config.postImageWriteDelayMs);
yield* rejectIfClosed;
const cached: CachedImage = {
id,
placeholder,
filePath,
mimeType,
createdAt: Date.now(),
sourceHash,
...(sourcePath ? { sourcePath } : {}),
};
const nextState: ImageCacheState = {
...state,
nextImageId: id + 1,
imagesByPlaceholder: new Map(state.imagesByPlaceholder).set(placeholder, cached),
};
yield* writeManifest(nextState);
committed = true;
return [nextState, cached] as const;
}).pipe(Effect.ensuring(rollbackOrphans));
};
const cacheBytesEffect = (
bytes: Buffer,
detectedMime: string,
sourcePath?: string,
): Effect.Effect =>
SynchronizedRef.modifyEffect(ref, (state) =>
cacheBytesLocked(state, bytes, detectedMime, sourcePath).pipe(
Effect.map(([nextState, cached]) => [Effect.succeed(cached), nextState] as const),
),
).pipe(Effect.flatten);
const cacheBytes = (
bytes: Buffer,
detectedMime: string,
sourcePath?: string,
): Effect.Effect =>
runTracked(cacheBytesEffect(bytes, detectedMime, sourcePath));
const cacheImageFileEffect = (filePath: string, sourcePath?: string) =>
tryIo("readImageFile", async () => {
const { readFile, stat } = await import("node:fs/promises");
const info = await stat(filePath);
if (!info.isFile() || info.size === 0 || info.size > MAX_SOURCE_BYTES) return null;
const bytes = await readFile(filePath);
const detectedMime = detectMimeType(filePath, bytes);
if (!detectedMime) return null;
return { bytes, detectedMime };
}, filePath).pipe(
Effect.flatMap((parsed) =>
parsed
? cacheBytesEffect(parsed.bytes, parsed.detectedMime, sourcePath)
: Effect.succeed(null),
),
);
const cacheImageFile = (filePath: string, sourcePath?: string) =>
runTracked(cacheImageFileEffect(filePath, sourcePath));
const cacheExistingImage = (sourcePath: string) =>
cacheImageFile(sourcePath, sourcePath).pipe(
Effect.flatMap((cached) =>
cached && basename(sourcePath).startsWith("pi-clipboard-") && isInsideTmpDir(sourcePath)
? tryIoBestEffort("unlink", async () => {
const { unlink } = await import("node:fs/promises");
await unlink(sourcePath);
}, sourcePath).pipe(Effect.as(cached))
: Effect.succeed(cached),
),
);
const loadManifestEffect = SynchronizedRef.modifyEffect(ref, (state) =>
Effect.gen(function* () {
const { readFile } = yield* Effect.promise(() => import("node:fs/promises"));
const { existsSync } = yield* Effect.promise(() => import("node:fs"));
const next: ImageCacheState = {
...state,
imagesByPlaceholder: new Map(),
nextImageId: 1,
};
let dropped = false;
const readResult = yield* tryIo(
"readManifest",
() => readFile(state.manifestPath, "utf8"),
state.manifestPath,
).pipe(Effect.orElseSucceed(() => null));
if (readResult) {
const manifest = parseManifest(readResult);
for (const image of manifest.images ?? []) {
next.nextImageId = Math.max(next.nextImageId, image.id + 1);
if (!existsSync(image.filePath)) {
dropped = true;
continue;
}
next.imagesByPlaceholder.set(image.placeholder, image);
}
}
const persist = dropped ? writeManifest(next) : Effect.void;
return [persist, next] as const;
}),
).pipe(Effect.flatten);
const cleanupOldCachesEffect = SynchronizedRef.get(ref).pipe(
Effect.flatMap((state) =>
tryIoBestEffort("cleanupOldCaches", async () => {
const { mkdir, readdir, rm, stat } = await import("node:fs/promises");
await mkdir(CACHE_ROOT, { recursive: true });
const entries = await readdir(CACHE_ROOT, { withFileTypes: true });
const now = Date.now();
await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map(async (entry) => {
const fullPath = join(CACHE_ROOT, entry.name);
if (fullPath === state.cacheDir) return;
try {
const [info, contents] = await Promise.all([stat(fullPath), readdir(fullPath)]);
const expired = now - info.mtimeMs > CACHE_TTL_MS;
const isEmpty = contents.length === 0;
const onlyManifest = contents.length === 1 && contents[0] === "manifest.json";
if (expired || isEmpty || onlyManifest) {
await rm(fullPath, { recursive: true, force: true });
}
} catch {
// Per-entry cleanup failures are ignored.
}
}),
);
}),
),
);
const touchCacheDirEffect = SynchronizedRef.get(ref).pipe(
Effect.flatMap((state) =>
tryIoBestEffort("utimes", async () => {
const { utimes } = await import("node:fs/promises");
const now = new Date();
await utimes(state.cacheDir, now, now);
}, state.cacheDir),
),
);
/** Clipboard caching is best-effort: per-file IO failures become unreadable paths. */
const cacheClipboardFile = (path: string) =>
cacheImageFileEffect(path, path).pipe(
Effect.catchIf(
(error): error is ImageCacheIoError => error instanceof ImageCacheIoError,
() => Effect.succeed(null),
),
);
const readMacClipboardImagesEffect =
process.platform === "darwin"
? SynchronizedRef.get(ref).pipe(
Effect.flatMap((state) =>
tryIoBestEffort("readMacClipboard", async () => {
const { mkdir } = await import("node:fs/promises");
await mkdir(state.cacheDir, { recursive: true });
const rawPath = join(state.cacheDir, `clipboard-${randomUUID()}.raw`);
const quotedPath = JSON.stringify(rawPath);
const script = `
ObjC.import('AppKit');
ObjC.import('Foundation');
const out = ${quotedPath};
const pb = $.NSPasteboard.generalPasteboard;
let result = { kind: 'none' };
const options = $({
NSPasteboardURLReadingFileURLsOnlyKey: 1,
NSPasteboardURLReadingContentsConformToTypesKey: ['public.image'],
});
const urls = pb.readObjectsForClassesOptions($([$.NSURL]), options);
const paths = [];
if (urls) {
for (let i = 0; i < urls.count; i++) {
const path = ObjC.unwrap(urls.objectAtIndex(i).path);
if (path) paths.push(path);
}
}
if (paths.length > 0) result = { kind: 'files', paths: paths };
if (result.kind === 'none') {
const candidates = [
'public.png', 'public.jpeg', 'com.compuserve.gif', 'org.webmproject.webp',
'public.webp', 'public.heic', 'public.heif', 'public.tiff',
];
for (const uti of candidates) {
const data = pb.dataForType(uti);
if (data && data.length > 0) {
if (!data.writeToFileAtomically(out, true)) throw new Error('failed to write clipboard image');
result = { kind: 'data' };
break;
}
}
}
JSON.stringify(result);
`;
try {
const { stdout } = await execFileAsync("osascript", ["-l", "JavaScript", "-e", script], {
timeout: 5_000,
maxBuffer: 256 * 1024,
});
const result = JSON.parse(stdout.trim()) as ClipboardScriptResult;
return { result, rawPath };
} catch {
return { result: { kind: "none" } as ClipboardScriptResult, rawPath };
}
}).pipe(
Effect.flatMap((payload) => {
if (!payload) return Effect.succeed({ images: [], unreadable: [] });
const { result, rawPath } = payload;
if (result.kind === "files") {
return Effect.all(
result.paths.map((path) =>
cacheClipboardFile(path).pipe(
Effect.map((image) => ({ path, image })),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((entries) => {
const images: CachedImage[] = [];
const unreadable: string[] = [];
for (const entry of entries) {
if (entry.image) images.push(entry.image);
else unreadable.push(entry.path);
}
return { images, unreadable };
}),
);
}
if (result.kind === "data") {
return cacheClipboardFile(rawPath).pipe(
Effect.map((cached) => ({
images: cached ? [cached] : [],
unreadable: [] as string[],
})),
Effect.ensuring(
tryIoBestEffort("unlink", async () => {
const { unlink } = await import("node:fs/promises");
await unlink(rawPath);
}, rawPath),
),
);
}
return Effect.succeed({ images: [], unreadable: [] }).pipe(
Effect.ensuring(
tryIoBestEffort("unlink", async () => {
const { unlink } = await import("node:fs/promises");
await unlink(rawPath);
}, rawPath),
),
);
}),
),
),
)
: Effect.succeed({ images: [], unreadable: [] });
const readMacClipboardTextEffect =
process.platform === "darwin"
? tryIoBestEffort("readMacClipboardText", async () => {
const { stdout } = await execFileAsync("pbpaste", [], {
timeout: 5_000,
maxBuffer: 8 * 1024 * 1024,
});
return stdout.length > 0 ? stdout : null;
}).pipe(Effect.map((text) => text ?? null))
: Effect.succeed(null);
const toImageContentEffect = (cached: CachedImage) =>
tryIo("readFile", async () => {
const { readFile } = await import("node:fs/promises");
const bytes = await readFile(cached.filePath);
const resized =
cached.mimeType === "image/gif"
? null
: await resizeImage(bytes, cached.mimeType, { maxWidth: 2000, maxHeight: 2000 });
if (resized) {
return {
content: { type: "image" as const, mimeType: resized.mimeType, data: resized.data },
...(formatDimensionNote(resized) ? { note: formatDimensionNote(resized) } : {}),
};
}
if (bytes.length > MAX_INLINE_BYTES) {
return {
error: `${cached.placeholder} is too large to send inline (${Math.round(bytes.length / 1024)} KB)`,
};
}
return {
content: {
type: "image" as const,
mimeType: cached.mimeType,
data: bytes.toString("base64"),
},
};
}, cached.filePath);
const toImageContent = (cached: CachedImage) => runTracked(toImageContentEffect(cached));
const clearEffect = SynchronizedRef.modifyEffect(ref, (state) =>
tryIo("rm", async () => {
const { rm } = await import("node:fs/promises");
await rm(state.cacheDir, { recursive: true, force: true });
}, state.cacheDir).pipe(
Effect.as([
Effect.void,
{
...state,
imagesByPlaceholder: new Map(),
},
] as const),
),
).pipe(Effect.flatten);
const removeEmptyCacheDirEffect = SynchronizedRef.modifyEffect(ref, (state) => {
const effect =
state.imagesByPlaceholder.size === 0
? tryIoBestEffort("rm", async () => {
const { rm } = await import("node:fs/promises");
await rm(state.cacheDir, { recursive: true, force: true });
}, state.cacheDir)
: Effect.void;
return effect.pipe(Effect.as([Effect.void, state] as const));
}).pipe(Effect.flatten);
return ImageCacheRuntime.of({
init: (sessionId) =>
runTracked(
SynchronizedRef.set(ref, sessionState(sessionId)).pipe(
Effect.flatMap(() => cleanupOldCachesEffect),
Effect.flatMap(() => loadManifestEffect),
Effect.flatMap(() => touchCacheDirEffect),
),
),
imageCount: ensureOpen.pipe(
Effect.flatMap(() => SynchronizedRef.get(ref)),
Effect.map((state) => state.imagesByPlaceholder.size),
),
getImage: (placeholder) =>
ensureOpen.pipe(
Effect.flatMap(() => SynchronizedRef.get(ref)),
Effect.map((state) => state.imagesByPlaceholder.get(placeholder)),
),
listImages: ensureOpen.pipe(
Effect.flatMap(() => SynchronizedRef.get(ref)),
Effect.map((state) => [...state.imagesByPlaceholder.values()]),
),
previewData: previewEntryData,
cacheBytes,
cacheImageFile,
cacheExistingImage,
readMacClipboardImages: runTracked(readMacClipboardImagesEffect),
readMacClipboardText: runTracked(readMacClipboardTextEffect),
toImageContent,
touchCacheDir: runTracked(touchCacheDirEffect),
clear: runTracked(clearEffect),
close: ensureOpen.pipe(
Effect.andThen(
Effect.sync(() => {
MutableRef.set(closed, true);
}),
),
Effect.andThen(Effect.yieldNow),
Effect.andThen(waitForDrain()),
Effect.andThen(removeEmptyCacheDirEffect),
),
});
});
export const ImageCacheRuntimeLive: Layer.Layer = Layer.effect(
ImageCacheRuntime,
makeImageCacheRuntime(defaultRuntimeConfig),
);
export function createImageCacheRuntime(options?: ImageCacheRuntimeOptions) {
return ManagedRuntime.make(
Layer.effect(ImageCacheRuntime, makeImageCacheRuntime(resolveRuntimeConfig(options))),
);
}
export type ImageCacheRuntimeInstance = ReturnType;
export async function runImageCache(
runtime: ImageCacheRuntimeInstance,
effect: Effect.Effect,
options: { signal?: AbortSignal } = {},
): Promise {
const exit = await runtime.runPromiseExit(
effect,
options.signal ? { signal: options.signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw abortError();
}
const failure = Cause.findFail(exit.cause);
if (Result.isSuccess(failure)) throw failure.success.error;
const [first] = Cause.prettyErrors(exit.cause);
throw new Error(first?.message ?? Cause.pretty(exit.cause));
}
function abortError(): Error {
const error = new Error("Image cache operation was aborted");
error.name = "AbortError";
return error;
}