Skip to content

Latest commit

 

History

History
162 lines (109 loc) · 8.41 KB

File metadata and controls

162 lines (109 loc) · 8.41 KB

Image.Plug

A pluggable Plug-based image server for Elixir. Maps URLs to a canonical image-processing pipeline executed via the image library, with named, stored variants. Ships a Cloudflare Images URL provider out of the box.

Companion: rendering responsive markup

For Phoenix LiveView apps, the image_components library provides a <.image> (and <.picture>) component that builds best-practice responsive markup against the same Cloudflare URL grammar image_plug parses. The two compose: image_plug serves the bytes; image_components writes the <img srcset sizes> / <picture type media> markup that asks for them.

Why

Pluggable URL grammars mean you can swap your image-CDN's URL syntax (Cloudflare Images, imgix, Imgur, …) without changing the source resolver, the variant store, or the rest of your application. The same canonical pipeline drives every transform.

  • Plug-based — mounts under any prefix from a Plug.Router or a Phoenix endpoint.
  • Streamingimage decodes the source progressively (Image.open/2 for files; Image.from_req_stream/2 for HTTP) and the encoder pipes its output through Plug.Conn.send_chunked/2 + Plug.Conn.chunk/2 so libvips never materialises the full encoded body in BEAM memory.
  • Cloudflare-compatible — recognises both /cdn-cgi/image/<options>/<source> and imagedelivery.net/<account>/<image-id>/<variant-or-options> URL forms; supports the documented option set including width, height, fit, gravity (named, compass, and XxY), dpr, quality, format (incl. auto content-negotiation and json metadata), metadata, anim, compression, background, blur, sharpen, brightness, contrast, gamma, saturation, rotate, flip, trim, border, segment, onerror, and a draw= URL grammar for overlays.
  • Variants — named, stored pipelines that any URL can reference. Provider-neutral: any provider can resolve /.../<variant-name> against the same store. Includes an HTTP admin plug for variant CRUD.
  • Cache-aware — strong ETag derived from the source's etag_seed and the normalised pipeline's fingerprint; If-None-Match returns 304 without re-encoding; sensible Cache-Control defaults; Vary: Accept for format=auto.
  • Soft AVIF fallback — if libvips lacks AVIF write support, requests for format=avif encode as WebP and the response is tagged with x-image-plug-format-fallback: avif->webp. Detected once at boot.
  • Friendly error policy — defaults to a placeholder PNG in dev (so broken URLs render visibly in the browser) and to streaming the original source bytes in prod (so a transform bug doesn't break the page).

Installation

Add :image_plug to your dependencies:

def deps do
  [
    {:image_plug, "~> 0.1"},
    {:req, "~> 0.5"}  # optional, for the HTTP source resolver
  ]
end

The :image library is a transitive dependency. Make sure your build has libvips 8.x available.

Quick start

Mount the request plug under your image path and configure a source resolver:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug Image.Plug,
    provider: {Image.Plug.Provider.Cloudflare,
               mount: "/img",
               hosted_account_hash: "abc123"},
    source_resolver: {Image.Plug.SourceResolver.Composite,
                      file: [root: Path.expand("priv/static/uploads")],
                      http: [allowed_hosts: ["assets.example.com"]]}

  plug MyAppWeb.Router
end

Then a request to https://example.com/img/cdn-cgi/image/width=600,fit=cover,format=auto/photos/sunset.jpg resolves the source, runs the pipeline, content-negotiates the format (AVIF → WebP → JPEG fallback), and streams the result.

Variants

Variants are reusable named pipelines. The hosted URL form /<account>/<image-id>/<variant-name> resolves against the configured Image.Plug.VariantStore.

Define variants at boot:

# config/config.exs
config :image_plug,
  variants: [
    {"thumbnail", "width=200,height=200,fit=cover,format=webp"},
    {"hero",      "width=1600,format=auto,quality=82"}
  ]

…or programmatically:

Image.Plug.put_variant("thumbnail", "width=200,height=200,fit=cover,format=webp")
{:ok, variant} = Image.Plug.get_variant("thumbnail")
:ok = Image.Plug.delete_variant("thumbnail")

The implicit "public" variant is always seeded and resolves to the empty pipeline (Cloudflare's "no transforms" default).

HTTP admin API

Mount Image.Plug.Admin under whatever path you protect with auth:

forward "/admin/variants",
  to: Image.Plug.Admin,
  init_opts: [provider: Image.Plug.Provider.Cloudflare]

Routes mirror Cloudflare's variant API:

Method Path Action
GET / List all variants.
GET /:name Fetch one variant.
POST / Create a variant. 409 on name conflict.
PUT /:name Upsert.
PATCH /:name Partial update.
DELETE /:name Delete.

Bodies use the canonical JSON shape {"name": ..., "options": ..., "metadata": {...}, "never_require_signed_urls": false}. The plug does not authenticate requests — wrap it in your host's auth pipeline.

Configuration reference

Image.Plug.init/1 accepts:

Option Default Meaning
:provider required {module, opts} for an Image.Plug.Provider.
:source_resolver required {module, opts} for an Image.Plug.SourceResolver.
:variant_store {Image.Plug.VariantStore.ETS, []} {module, opts}.
:on_error :auto :auto | :render_error_image | :fallback_to_source | :status_text | :raise | {:status, code}. See "Error policy" below.
:max_pixels 25_000_000 Soft upper bound on output pixel count.
:request_timeout 10_000 Per-request budget in ms.
:telemetry_prefix [:image_plug] Atom list prepended to telemetry event names.

Error policy

:on_error controls what happens when the pipeline can't produce a result:

  • :auto (default) — selects :render_error_image in :dev/:test and :fallback_to_source in :prod. The selection key is Application.get_env(:image_plug, :env, Mix.env()) so releases behave correctly.

  • :render_error_image — generates a 400×300 PNG placeholder with the error tag and message painted on. Returns 200 so the broken image still renders visibly in browsers. Cache-Control: no-store.

  • :fallback_to_source — re-encodes the loaded source image in its source format and streams it. Logs the failure at :error. Returns 200 with x-image-plug-error: <tag> and Cache-Control: no-store. Falls through to :status_text if the source itself failed to load.

  • :status_text — text/plain body, status code mapped from the error tag (Image.Plug.Error.status/1), x-image-plug-error header.

  • :raise — propagate the error.

  • {:status, code} — use the given status code with a text body.

Telemetry

The plug emits two events per request under the configured :telemetry_prefix (default [:image_plug]):

  • [:image_plug, :request, :start] — at request entry. Measurements: %{system_time}. Metadata: %{request_path, provider}.

  • [:image_plug, :request, :stop] — at request completion. Measurements: %{duration} (monotonic native units). Metadata: %{request_path, provider, status, error_tag}.

  • [:image_plug, :request, :exception] — only fired if a handler raises. Measurements: %{duration}. Metadata includes %{kind, reason, stacktrace}.

AVIF support

AVIF requires libvips built with libheif plus an AV1 encoder (libaom or librav1e). On builds without those, requests for format=avif are served as WebP with x-image-plug-format-fallback: avif->webp. A warning is logged once at startup. Check at runtime with Image.Plug.Capabilities.avif_write?/0.

Caching

Every successful response carries:

  • A strong ETag derived from meta.etag_seed and the normalised pipeline's fingerprint. Two URLs that differ only in option order produce the same ETag.
  • Cache-Control: public, max-age=3600, stale-while-revalidate=86400 by default. The source resolver can override via meta.cache_control.
  • Vary: Accept so the cache differentiates between content-negotiated formats.

Conditional GET via If-None-Match returns 304 without invoking libvips.

License

Apache-2.0.