npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

runtime-image-transformer

v3.0.0

Published

Self-hosted, Sharp-powered runtime image transforms for server-side web frameworks (Next.js, TanStack Start, and anything else built on the Web `Request`/`Response` API).

Readme

runtime-image-transformer

Self-hosted, Sharp-powered runtime image transforms for server-side web frameworks (Next.js, TanStack Start, and anything else built on the Web Request/Response API).

If you want something closer to “Imgix/Cloudinary, but inside your own app,” this package gives you:

  • A route handler that fetches an upstream image, runs a small set of transforms via Sharp, and caches results on disk. It's a plain (req: Request) => Promise<Response>, so it drops into any framework whose routes speak the Web Fetch API.
  • A URL builder that creates transform URLs you can use from your app.

On Next.js specifically, this is an alternative to the built-in image optimization (next/image + the /_next/image optimizer), which is intentionally constrained and opinionated.

This tends to work best on managed app hosting where you run a Node runtime and can put a CDN in front:

  • DigitalOcean App Platform (CDN-enabled)
  • Render, Fly.io, Railway, Heroku
  • AWS App Runner / ECS / EC2 (often fronted by CloudFront)
  • Google Cloud Run (often fronted by Cloud CDN)
  • Any setup fronted by Cloudflare / Fastly / etc.

Installation

The examples below use the Next.js App Router; see the collapsible section in step 2 for TanStack Start and other frameworks.

1) Install:

yarn add runtime-image-transformer sharp

2) Add a route handler

Create a route handler at some path, for example: src/app/image/route.ts

// src/app/api/image/route.ts

import { createImageTransformRouteHandler } from "runtime-image-transformer/server";
import sharp from "sharp";

export const runtime = "nodejs";

const handler = createImageTransformRouteHandler({
  // Required. The trusted origin that `source` paths are fetched from. Callers
  // can only ever request paths under this origin, which is the SSRF protection.
  sourceOrigin: process.env.IMAGE_SOURCE_ORIGIN ?? "https://images.example.com",
  // Required. Sharp is a peer dependency — install it and pass the instance in.
  sharp,
});

export const GET = handler;

The handler is a plain (req: Request) => Promise<Response>. TanStack Start server routes hand you a context object, so unwrap request in a one-line adapter:

// src/routes/api/image.ts

import { createServerFileRoute } from "@tanstack/react-start/server";
import { createImageTransformRouteHandler } from "runtime-image-transformer/server";
import sharp from "sharp";

const handler = createImageTransformRouteHandler({
  // Required. The trusted origin that `source` paths are fetched from.
  sourceOrigin: process.env.IMAGE_SOURCE_ORIGIN ?? "https://images.example.com",
  // Required. Sharp is a peer dependency — install it and pass the instance in.
  sharp,
});

export const ServerRoute = createServerFileRoute().methods({
  GET: ({ request }) => handler(request),
});

The same pattern works for Hono, Remix, SvelteKit, and bare Request-based servers.

3) Create a URL builder module

Create a helper for example: src/lib/imageUrlBuilder.ts

import { createImageUrlBuilder } from "runtime-image-transformer";

export const imageUrlBuilder = createImageUrlBuilder({
  // The path of the route above. A root-relative path is recommended: the
  // builder then emits root-relative URLs, so there's no origin to configure at
  // build time (and nothing to expose client-side). An absolute URL — e.g. a
  // CDN-hosted route — also works.
  apiRouteUrl: "/api/image",
});

4) Start writing URLs:

Then build URLs like:

// src/components/MyImage.tsx

import { imageUrlBuilder } from "../lib/imageUrlBuilder";

export function MyImage() {
  return (
    <img
      src={imageUrlBuilder({
        // A path, resolved server-side against the handler's `sourceOrigin`.
        source: "/cat.jpg",
        fmt: "webp",
        w: 800,
        q: 80,
      })}
    />
  );
}

API reference

createImageTransformRouteHandler(options)

Import from: runtime-image-transformer/server

Returns: (req: Request) => Promise<Response> (a Web Fetch handler — compatible with Next.js Route Handlers, TanStack Start server routes, etc.)

Options

  • sourceOrigin: string (required)

    • Description: The trusted, absolute origin that source paths are resolved and fetched against, e.g. "https://images.example.com". This is the handler's SSRF protection: because source is always a path joined to this fixed, server-side origin, callers can never make the server fetch an arbitrary host. Any source that resolves off-origin (an absolute URL, or a protocol-relative //host value) is rejected with 400.
    • Note: This is intentionally not derived from the incoming request — the Host header is attacker-controlled, so trusting it would reintroduce SSRF and enable cache poisoning.
    • Default: none (required; must be an http(s) URL or the handler throws at construction)
  • sharp: typeof import("sharp").default (required)

    • Description: The Sharp factory (the module's default export). Sharp is a peer dependency, not bundled: install it in your app and pass the instance in (import sharp from "sharp"). This lets your app pin Sharp's version and apply any global configuration (concurrency, SIMD, a custom/self-hosted build) before handing it over.
    • Default: none (required)
  • cachePlugin: CachePlugin (optional)

    • Description: Cache backend for transformed images. A CachePlugin is an object with async read(key) / write(key, entry) methods, so you can back the cache with whatever store you like — the on-disk createFileSystemCachePlugin, or a shared/remote store such as S3-compatible object storage. See createFileSystemCachePlugin and CachePlugin.
    • Default: undefinedserver-side caching is disabled, and every request is transformed from the upstream source. Omit cachePlugin on runtimes without a writable/persistent filesystem (e.g. workers), or when a CDN in front of the handler is expected to do the caching. Pass createFileSystemCachePlugin() to opt into the on-disk cache.
  • cacheControl: string (optional)

    • Description: Value for the response Cache-Control header.
    • Default: "public, max-age=31536000, immutable"
  • maxSourceBytes: number (optional)

    • Description: Maximum size, in bytes, of an upstream source image the handler will download, to guard against memory exhaustion. Enforced both against the upstream Content-Length (rejected before downloading) and while streaming the body (so a missing or dishonest Content-Length can't get around it). A source over the limit yields 502. This bounds bytes buffered from the network; maxInputPixels bounds decoded pixels.
    • Default: 20 * 1024 * 1024 (20 MiB)
  • maxInputPixels: number (optional)

    • Description: Maximum number of pixels (width × height) in the decoded source image (maps to Sharp's limitInputPixels). Guards against a "pixel bomb" — a tiny compressed file that decodes to an enormous canvas, which maxSourceBytes can't catch. A source over the limit yields 502.
    • Default: 3840 * 3840 (~15 megapixels)
  • fetchTimeoutMs: number (optional)

    • Description: Timeout for the upstream fetch. Bounds the whole upstream interaction — connect, response, and body download — so a slow or hanging source can't tie up the request indefinitely. On timeout the handler responds with 502.
    • Default: 10_000 (10 seconds)
  • allowedFormats: Array<"preserve" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff"> (optional)

    • Description: Output formats a request may ask for via fmt. A request whose effective format (fmt, or "preserve" when omitted) isn't in this list is rejected with 400. Use it to keep the served surface to the formats you actually want — e.g. modern codecs only, or dropping "preserve" to force every response to be re-encoded.
    • Default: ["preserve", "webp", "avif"]

Note: Upstream redirects are not followed (redirect: "manual"). A redirect is treated as a failed fetch (502) so it can't be used to bounce the server off sourceOrigin to an internal address.

Behavior notes

  • Format defaulting: if fmt is omitted in the URL, it defaults to "preserve".
  • Quality defaulting: if q is omitted, the handler uses 100.
  • Resize semantics: if w and/or h is provided, the image is resized with:
    • fit: "inside" (or the provided fit)
    • withoutEnlargement: true
  • Auto-orient: Sharp rotate() is applied to respect EXIF orientation.
  • Caching: disabled by default. Pass a cachePlugin to enable it — createFileSystemCachePlugin writes to disk (so your runtime must have a writable filesystem), or supply a custom CachePlugin to cache elsewhere (e.g. shared object storage). With no cachePlugin, every request is transformed fresh — rely on a CDN in front of the handler to absorb load.

createFileSystemCachePlugin(options)

Import from: runtime-image-transformer/server

Returns: a CachePlugin that stores transformed images on the local filesystem (sharded by key prefix). Pass it as the handler's cachePlugin to enable on-disk caching.

Options

  • cacheDir: string (optional)
    • Description: Directory on disk where transformed images are cached.
    • Default: path.join(process.cwd(), ".transform-cache")
import {
  createImageTransformRouteHandler,
  createFileSystemCachePlugin,
} from "runtime-image-transformer/server";
import sharp from "sharp";

const handler = createImageTransformRouteHandler({
  sourceOrigin: "https://images.example.com",
  sharp,
  cachePlugin: createFileSystemCachePlugin({ cacheDir: "/var/cache/images" }),
});

CachePlugin

Import from: runtime-image-transformer/server (type-only)

The pluggable cache contract. Implement it to back the cache with any store — for example, shared S3-compatible object storage so a fleet of instances share one cache:

type CacheEntry = {
  /** The encoded image bytes. */
  body: Uint8Array;
  /** The `Content-Type` to serve the bytes with. */
  contentType: string;
};

type CachePlugin = {
  /** Resolve to the entry on a hit, or `null` on a miss. */
  read: (key: string) => Promise<CacheEntry | null>;
  /** Persist an entry under `key`. */
  write: (key: string, entry: CacheEntry) => Promise<void>;
};

The handler derives an opaque, filesystem-safe key for each transform, calls read(key) before doing any work, and write(key, entry) after producing a result.

import {
  createImageTransformRouteHandler,
  type CachePlugin,
} from "runtime-image-transformer/server";
import sharp from "sharp";

// Example: an S3-compatible object-storage backed cache.
const s3Cache: CachePlugin = {
  read: async (key) => {
    const object = await bucket.get(key);
    if (!object) return null;
    return {
      body: new Uint8Array(await object.arrayBuffer()),
      contentType: object.httpMetadata.contentType,
    };
  },
  write: async (key, { body, contentType }) => {
    await bucket.put(key, body, { httpMetadata: { contentType } });
  },
};

const handler = createImageTransformRouteHandler({
  sourceOrigin: "https://images.example.com",
  sharp,
  cachePlugin: s3Cache,
});

createImageUrlBuilder(options)

Import from: runtime-image-transformer

Returns: a function (config: TransformConfig) => string that builds a transform URL.

Options

  • apiRouteUrl: string (required)
    • Description: The transform route. Usually a root-relative path like "/api/image" (the builder then emits root-relative URLs, so no origin is needed at build time). An absolute URL also works.
    • Default: none

Transform config + query parameters

The transform URL uses these query params:

  • source: string (required)
    • A path to the upstream image, e.g. "/photos/cat.jpg". Resolved server-side against the handler's sourceOrigin; values that resolve to a different origin are rejected.
  • fmt: "preserve" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" (optional)
    • If omitted, it defaults to "preserve" (the upstream bytes are returned untouched).
    • png and gif ignore q (PNG is lossless; Sharp's GIF encoder has no quality option).
    • Which of these a request may actually use is gated by the handler's allowedFormats (default ["preserve", "webp", "avif"]); a disallowed fmt yields 400.
  • w: number (optional)
    • Integer in [1..16384]
  • h: number (optional)
    • Integer in [1..16384]
  • fit: "cover" | "contain" | "fill" | "inside" | "outside" (optional)
    • Only used when resizing (w and/or h is provided)
    • If omitted, it defaults to "inside".
  • q: number (optional)
    • Integer in [0..100]
    • Default: 100

CDN caching

This package is designed to sit behind a CDN, so responses are built to cache well:

  • Long-lived, immutable responses: the default Cache-Control is public, max-age=31536000, immutable. A transform URL fully describes its output, so the result never changes for a given URL — configure it via cacheControl if you want a different policy.

  • Explicit format, no Vary: Accept: the output format is chosen by the fmt param, not negotiated from the Accept header. This deliberately avoids Vary: Accept, which fragments cache entries and is handled inconsistently across CDNs.

  • No-Vary-Search: every successful response carries a No-Vary-Search header advertising the only params that affect the output:

    No-Vary-Search: key-order, params, except=("w" "h" "fit" "fmt" "q" "source")

    This tells caches to ignore param order and any unrelated params (e.g. utm_* tracking), so ?source=/cat.jpg&w=800&utm_campaign=x collapses onto the same entry as ?w=800&source=/cat.jpg. It mirrors how the handler computes its own cache key. Support is currently strongest in the browser HTTP cache and Speculation-Rules prefetch (Chromium); CDN support is still emerging.

Configuring the CDN cache key. Because No-Vary-Search isn't yet widely honored by CDNs, get the same normalization at the edge by keying only on the significant params (w, h, fit, fmt, q, source) and ignoring the rest:

  • CloudFront — a Cache Policy with a query-string allowlist of those params.
  • Cloudflare — Cache Rules / a custom Cache Key including only those params.
  • Fastly — sort the query and strip unknown params in VCL.

If all your URLs come from createImageUrlBuilder, they're already canonical (fixed param order, no extras), so this mostly matters for hand-built or tracking-decorated URLs.

License

See LICENSE.md.