diff --git a/deno/api/mod.ts b/deno/api/mod.ts index 31718703..512b5f95 100644 --- a/deno/api/mod.ts +++ b/deno/api/mod.ts @@ -1,4 +1,4 @@ -import { SSESource } from "@jsr/planigale__sse"; +import { SSESource, type SSESourceInit } from "@jsr/planigale__sse"; import { ApiErrorResponse, Channel, @@ -167,7 +167,7 @@ class API extends EventTarget { headers: { Authorization: `Bearer ${this.token}`, }, - }); + } as SSESourceInit); if (!this.source) return; this.emit(new CustomEvent("con:open", { detail: {} })); // @ts-ignore For some reason the AsyncIterator is not recognized diff --git a/deno/storage/src/core/env.ts b/deno/storage/src/core/env.ts new file mode 100644 index 00000000..38aa636c --- /dev/null +++ b/deno/storage/src/core/env.ts @@ -0,0 +1,13 @@ +export const getEnvInt = (name: string, fallback: number): number => { + let raw: string | undefined; + try { + raw = Deno.env.get(name); + } catch { + return fallback; + } + if (raw === undefined) return fallback; + const value = Number(raw); + if (Number.isInteger(value) && value > 0) return value; + console.warn(`[storage] invalid ${name}="${raw}", using ${fallback}`); + return fallback; +}; diff --git a/deno/storage/src/core/mod.ts b/deno/storage/src/core/mod.ts index 26a9e06b..19846bd6 100644 --- a/deno/storage/src/core/mod.ts +++ b/deno/storage/src/core/mod.ts @@ -1,7 +1,7 @@ -import { PhotonImage, resize, SamplingFilter } from "@cf-wasm/photon/node"; import type { Config } from "@quack/config"; import type { FileData, FileOpts } from "./types.ts"; import { files } from "./store/mod.ts"; +import { getResizePool } from "./resizePool.ts"; import { ApiError } from "@planigale/planigale"; type ScalingOpts = { @@ -87,33 +87,29 @@ class Files { width?: number, height?: number, ): Promise | null> { - let img: PhotonImage | undefined; - let out: PhotonImage | undefined; + const pool = getResizePool(); + if (!pool.hasCapacity()) { + console.warn("[storage] resize pool saturated, serving original"); + return null; + } + let bytes: Uint8Array; try { - const bytes = new Uint8Array( - await new Response(file.stream).arrayBuffer(), - ); - img = PhotonImage.new_from_byteslice(bytes); - - const ow = img.get_width(); - const oh = img.get_height(); - let w = width || 0; - let h = height || 0; - if (!w) w = Math.max(1, Math.round((ow / oh) * h)); - if (!h) h = Math.max(1, Math.round((oh / ow) * w)); - - out = resize(img, w, h, SamplingFilter.Lanczos3); - const result = file.contentType === "image/png" - ? out.get_bytes() - : out.get_bytes_jpeg(90); - return new Blob([new Uint8Array(result)]).stream(); + bytes = new Uint8Array(await new Response(file.stream).arrayBuffer()); } catch (e) { - console.warn("[storage] thumbnail resize failed, serving original", e); + console.warn("[storage] reading image to resize failed", e); + return null; + } + const resized = await pool.resize( + bytes, + width || 0, + height || 0, + file.contentType === "image/png", + ); + if (!resized) { + console.warn("[storage] thumbnail resize failed, serving original"); return null; - } finally { - img?.free(); - out?.free(); } + return new Blob([resized]).stream(); } } diff --git a/deno/storage/src/core/resizePool.ts b/deno/storage/src/core/resizePool.ts new file mode 100644 index 00000000..98f3ce26 --- /dev/null +++ b/deno/storage/src/core/resizePool.ts @@ -0,0 +1,154 @@ +import type { ResizeRequest, ResizeResponse } from "./resizeWorker.ts"; +import { getEnvInt } from "./env.ts"; + +type Pending = (bytes: Uint8Array | null) => void; + +type Task = { + request: ResizeRequest; + resolve: Pending; +}; + +type Active = { + resolve: Pending; + timer: number; +}; + +const DEFAULT_WORKERS = 2; +const DEFAULT_TIMEOUT_MS = 30_000; + +export class ResizePool { + private workers = new Set(); + private idle: Worker[] = []; + private queue: Task[] = []; + private active = new Map(); + private started = false; + private closed = false; + + constructor( + private readonly size: number, + private readonly maxPending: number = size * 4, + private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, + ) {} + + private start() { + if (this.started) return; + this.started = true; + for (let i = 0; i < this.size; i++) { + this.idle.push(this.spawn()); + } + } + + private spawn(): Worker { + const worker = new Worker( + new URL("./resizeWorker.ts", import.meta.url), + { type: "module" }, + ); + this.workers.add(worker); + worker.onmessage = (event: MessageEvent) => { + const entry = this.active.get(worker); + this.active.delete(worker); + if (entry) { + clearTimeout(entry.timer); + entry.resolve(event.data.ok ? event.data.bytes : null); + } + this.release(worker); + }; + worker.onerror = (event) => { + event.preventDefault(); + this.discard(worker); + }; + return worker; + } + + private discard(worker: Worker) { + const entry = this.active.get(worker); + this.active.delete(worker); + if (entry) { + clearTimeout(entry.timer); + entry.resolve(null); + } + this.workers.delete(worker); + worker.terminate(); + if (this.closed) return; + this.idle.push(this.spawn()); + this.drain(); + } + + private release(worker: Worker) { + if (this.closed) return; + this.idle.push(worker); + this.drain(); + } + + private drain() { + while (this.queue.length > 0 && this.idle.length > 0) { + const worker = this.idle.shift()!; + const task = this.queue.shift()!; + const timer = setTimeout(() => { + console.warn("[storage] thumbnail resize timed out, serving original"); + this.discard(worker); + }, this.timeoutMs); + this.active.set(worker, { resolve: task.resolve, timer }); + worker.postMessage(task.request, [task.request.bytes.buffer]); + } + } + + hasCapacity(): boolean { + return !this.closed && + this.active.size + this.queue.length < this.maxPending; + } + + resize( + bytes: Uint8Array, + width: number, + height: number, + png: boolean, + ): Promise | null> { + if (this.closed) return Promise.resolve(null); + this.start(); + return new Promise | null>((resolve) => { + const request: ResizeRequest = { bytes, width, height, png }; + this.queue.push({ request, resolve }); + this.drain(); + }); + } + + close() { + this.closed = true; + for (const task of this.queue) { + task.resolve(null); + } + this.queue = []; + for (const entry of this.active.values()) { + clearTimeout(entry.timer); + entry.resolve(null); + } + this.active.clear(); + for (const worker of this.workers) { + worker.terminate(); + } + this.workers.clear(); + this.idle = []; + } +} + +let pool: ResizePool | undefined; + +export const getResizePool = (): ResizePool => { + if (!pool) { + const workers = getEnvInt("STORAGE_RESIZE_WORKERS", DEFAULT_WORKERS); + pool = new ResizePool( + workers, + getEnvInt("STORAGE_RESIZE_MAX_PENDING", workers * 4), + getEnvInt("STORAGE_RESIZE_TIMEOUT_MS", DEFAULT_TIMEOUT_MS), + ); + } + return pool; +}; + +export const closeResizePool = () => { + pool?.close(); + pool = undefined; +}; + +globalThis.addEventListener("unload", () => closeResizePool()); diff --git a/deno/storage/src/core/resizeWorker.ts b/deno/storage/src/core/resizeWorker.ts new file mode 100644 index 00000000..b4af4863 --- /dev/null +++ b/deno/storage/src/core/resizeWorker.ts @@ -0,0 +1,40 @@ +/// +import { PhotonImage, resize, SamplingFilter } from "@cf-wasm/photon/node"; + +export type ResizeRequest = { + bytes: Uint8Array; + width: number; + height: number; + png: boolean; +}; + +export type ResizeResponse = + | { ok: true; bytes: Uint8Array } + | { ok: false }; + +self.onmessage = (event: MessageEvent) => { + const { bytes, width, height, png } = event.data; + let img: PhotonImage | undefined; + let out: PhotonImage | undefined; + try { + img = PhotonImage.new_from_byteslice(bytes); + + const ow = img.get_width(); + const oh = img.get_height(); + let w = width || 0; + let h = height || 0; + if (!w) w = Math.max(1, Math.round((ow / oh) * h)); + if (!h) h = Math.max(1, Math.round((oh / ow) * w)); + + out = resize(img, w, h, SamplingFilter.Lanczos3); + const result = new Uint8Array( + png ? out.get_bytes() : out.get_bytes_jpeg(90), + ); + self.postMessage({ ok: true, bytes: result }, [result.buffer]); + } catch { + self.postMessage({ ok: false }); + } finally { + img?.free(); + out?.free(); + } +}; diff --git a/deno/storage/tests/resizePool.test.ts b/deno/storage/tests/resizePool.test.ts new file mode 100644 index 00000000..bc514383 --- /dev/null +++ b/deno/storage/tests/resizePool.test.ts @@ -0,0 +1,159 @@ +import { assert, assertEquals } from "@std/assert"; +import * as path from "@std/path"; +import { PhotonImage } from "@cf-wasm/photon/node"; + +import { ResizePool } from "../src/core/resizePool.ts"; + +const __dirname = new URL(".", import.meta.url).pathname; +const testImagePath = path.join(__dirname, "quack.png"); +const source = await Deno.readFile(testImagePath); + +const dimensions = (bytes: Uint8Array) => { + const img = PhotonImage.new_from_byteslice(bytes); + const size = { width: img.get_width(), height: img.get_height() }; + img.free(); + return size; +}; + +const original = dimensions(new Uint8Array(source)); + +const expectedHeight = (width: number) => + Math.max(1, Math.round((original.height / original.width) * width)); + +const expectedWidth = (height: number) => + Math.max(1, Math.round((original.width / original.height) * height)); + +Deno.test("ResizePool - resizes png by width and preserves aspect ratio", async () => { + const pool = new ResizePool(2); + try { + const result = await pool.resize(new Uint8Array(source), 100, 0, true); + assert(result, "should return resized bytes"); + assert(result.length > 0, "resized bytes should be non-empty"); + assert( + result.length < source.length, + "thumbnail should be smaller than the original", + ); + assertEquals(dimensions(result), { + width: 100, + height: expectedHeight(100), + }); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - resizes png by height only", async () => { + const pool = new ResizePool(1); + try { + const result = await pool.resize(new Uint8Array(source), 0, 50, true); + assert(result, "should return resized bytes"); + assertEquals(dimensions(result), { width: expectedWidth(50), height: 50 }); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - encodes jpeg output when not png", async () => { + const pool = new ResizePool(1); + try { + const result = await pool.resize(new Uint8Array(source), 80, 0, false); + assert(result, "should return resized bytes"); + assertEquals(result[0], 0xff, "jpeg magic byte 0"); + assertEquals(result[1], 0xd8, "jpeg magic byte 1"); + assertEquals(dimensions(result), { width: 80, height: expectedHeight(80) }); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - returns null for undecodable input", async () => { + const pool = new ResizePool(1); + try { + const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + const result = await pool.resize(garbage, 100, 0, true); + assertEquals(result, null); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - drains a queue larger than the pool", async () => { + const pool = new ResizePool(1); + try { + const widths = [40, 60, 80, 120]; + const results = await Promise.all( + widths.map((w) => pool.resize(new Uint8Array(source), w, 0, true)), + ); + for (const [i, result] of results.entries()) { + assert(result, `job ${i} should resolve`); + assertEquals(dimensions(result).width, widths[i]); + } + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - does not block the event loop", async () => { + const pool = new ResizePool(2); + let beats = 0; + const heartbeat = setInterval(() => beats++, 5); + try { + const widths = [50, 90, 130, 170, 210, 250]; + const results = await Promise.all( + widths.map((w) => pool.resize(new Uint8Array(source), w, 0, true)), + ); + assert(results.every((r) => r !== null), "all resizes should succeed"); + assert( + beats > 0, + "event loop should keep ticking while resizing (blocking impl ticks 0)", + ); + } finally { + clearInterval(heartbeat); + pool.close(); + } +}); + +Deno.test("ResizePool - reports no capacity beyond maxPending", async () => { + const pool = new ResizePool(1, 2); + try { + assert(pool.hasCapacity(), "empty pool has capacity"); + const a = pool.resize(new Uint8Array(source), 100, 0, true); + const b = pool.resize(new Uint8Array(source), 120, 0, true); + assertEquals(pool.hasCapacity(), false); + const [ra, rb] = await Promise.all([a, b]); + assert(ra && rb, "both queued jobs should still complete"); + assert(pool.hasCapacity(), "drained pool has capacity again"); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - times out a stuck job and stays usable", async () => { + const pool = new ResizePool(1, 4, 1); + try { + const first = await pool.resize(new Uint8Array(source), 100, 0, true); + assertEquals(first, null, "the job should time out to null"); + + let guard: number | undefined; + const stuck = new Promise<"stuck">((resolve) => { + guard = setTimeout(() => resolve("stuck"), 3000); + }); + const second = await Promise.race([ + pool.resize(new Uint8Array(source), 120, 0, true), + stuck, + ]); + clearTimeout(guard); + assertEquals(second, null, "pool must recover and not deadlock"); + } finally { + pool.close(); + } +}); + +Deno.test("ResizePool - resolves pending work as null on close", async () => { + const pool = new ResizePool(1); + const inflight = pool.resize(new Uint8Array(source), 100, 0, true); + const queued = pool.resize(new Uint8Array(source), 120, 0, true); + pool.close(); + assertEquals(await inflight, null); + assertEquals(await queued, null); +}); diff --git a/deno/storage/tests/resizeRoute.test.ts b/deno/storage/tests/resizeRoute.test.ts new file mode 100644 index 00000000..0d34d3c5 --- /dev/null +++ b/deno/storage/tests/resizeRoute.test.ts @@ -0,0 +1,64 @@ +import { Agent } from "@planigale/testing"; +import { assert, assertEquals } from "@std/assert"; +import * as path from "@std/path"; +import { PhotonImage } from "@cf-wasm/photon/node"; + +import { buildApp } from "../src/interfaces/http/mod.ts"; +import { initStorage } from "../src/core/mod.ts"; +import config from "./config.ts"; + +const __dirname = new URL(".", import.meta.url).pathname; +const testImagePath = path.join(__dirname, "quack.png"); + +const storage = initStorage(config); +const app = await buildApp(storage); + +const upload = async () => { + const agent = await Agent.from(app); + const res = await agent.request().post("/").file(testImagePath).expect(200); + const body = await res.json(); + return body.id as string; +}; + +Deno.test("GET /:id?w - serves a thumbnail decoded at the requested width", async () => { + const fileId = await upload(); + const agent = await Agent.from(app); + const res = await agent.request().get(`/${fileId}?w=100`).expect(200); + assertEquals(res.headers.get("content-type"), "image/png"); + + const bytes = new Uint8Array(await res.arrayBuffer()); + const img = PhotonImage.new_from_byteslice(bytes); + assertEquals(img.get_width(), 100); + assert(img.get_height() > 0 && img.get_height() < 151, "height scaled down"); + img.free(); +}); + +Deno.test("GET /:id?w - caches the generated thumbnail under its sized id", async () => { + const fileId = await upload(); + const agent = await Agent.from(app); + + const warm = await agent.request().get(`/${fileId}?w=100`).expect(200); + await warm.body?.cancel?.(); + + const cached = await agent.request().get(`/${fileId}-100x0`).expect(200); + assertEquals(cached.headers.get("content-type"), "image/png"); + const bytes = new Uint8Array(await cached.arrayBuffer()); + const img = PhotonImage.new_from_byteslice(bytes); + assertEquals(img.get_width(), 100); + img.free(); +}); + +Deno.test("GET /:id?w - repeated requests return identical cached bytes", async () => { + const fileId = await upload(); + const agent = await Agent.from(app); + + const first = new Uint8Array( + await (await agent.request().get(`/${fileId}?w=120`).expect(200)) + .arrayBuffer(), + ); + const second = new Uint8Array( + await (await agent.request().get(`/${fileId}?w=120`).expect(200)) + .arrayBuffer(), + ); + assertEquals(first, second); +});