first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# @suaveplan/caddy-imgproxy - Universal TypeScript port of the Caddy imgproxy URL rewriter — compact-grammar parser, aspect / pixel dimension resolver, gravity + operation translator, HMAC-SHA256 URL signer, full handler with Zod-validated config and OTel telemetry
Universal TypeScript port of the Caddy imgproxy URL rewriter.
Parses the compact `/u<op><gravity><dims><modifiers>/<path>.<format>`
grammar, resolves aspect-ratio + hint dimensions, translates gravity +
operations into imgproxy's URL DSL, signs the result with
HMAC-SHA256, and exposes a high-level `ImgproxyHandler` orchestrator
with injectable `Clock`, `LoggerLike`, and signing hash, plus
Zod-validated configuration and OTel telemetry.
<!-- begin:auto-docs -->
## Documentation tree
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
- [dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
- [calculate-dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
- [domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
- [extract-domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
- [parse](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
- [parse-url](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
- [signing](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
- [sign-path](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/caddy-imgproxy
```
## Quick start
```ts
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const handler = new ImgproxyHandler({
config: parseConfig({
upstream: "imgproxy:8080",
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11bbcc",
salt: "deadbeef",
}),
});
const result = handler.rewrite({
path: "/uvt9:16h1080/photos/hero.webp",
host: "images.example.com",
});
if (result.rewritten) {
forwardToImgproxy(result.targetPath);
} else if (result.reason === "passthrough") {
forwardToImgproxy(originalPath);
}
```
## Modules
- [parse](./src/parse/parse-url.md) — compact-grammar parser.
- [dimensions](./src/dimensions/calculate-dimensions.md) — aspect /
hint → pixel resolution.
- [gravity](./src/gravity/gravity.md) — grammar gravity → imgproxy
compass tokens.
- [operation](./src/operation/operation.md) — operation +
dimensions → imgproxy processing string.
- [signing](./src/signing/sign-path.md) — HMAC-SHA256 URL signing.
- [url-builder](./src/url-builder/url-builder.md) — composes the
final imgproxy path.
- [domain](./src/domain/extract-domain.md) — Host header → S3
sub-path.
- [handler](./src/handler/handler.md) — `ImgproxyHandler`
orchestrator.
- [config](./src/config/config.md) — Zod-validated `ImgproxyConfig`.
- [error-codes](./src/error-codes/error-codes.md) —
`CADDY_IMGPROXY_CODES` + `CaddyImgproxyError`.
- [telemetry](./src/telemetry/telemetry.md) — OTel counters +
histogram.
## Tier & dependencies
Universal — no DOM or Node-only APIs at the package boundary.
`sideEffects: false`. Internal deps: `@suaveplan/crypto-hash`,
`@suaveplan/error`, `@suaveplan/types`. External deps:
`@opentelemetry/api`, `tslib`, `zod`.

View File

@@ -0,0 +1,86 @@
# config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `config` module exposes the Zod schema and the validating
`parseConfig` function for the package's public `ImgproxyConfig`. It
is the single boundary where a raw object — typically loaded from
environment variables, JSON, or a Caddyfile shim — is converted into
the immutable, defaulted `ImgproxyConfig` that the URL builder and
the handler consume.
`parseConfig` raises stable, typed `CaddyImgproxyError` instances on
every failure path: `INVALID_OPTIONS` for general schema failures,
`INVALID_KEY` for a malformed `key`, and `INVALID_SALT` for a
malformed `salt`. The schema applies the same defaults the upstream
Go module ships with (`localhost:8080`, `images`, `800`) so a bare
`parseConfig({})` returns a usable production configuration.
## Features
- Zod schema is exported so callers can compose it into larger
configs.
- Defaults match the Go reference exactly.
- Hex validation built into the schema — even-length, lower-case
only.
- Distinct error codes for key vs salt failures so consumers can
surface targeted remediation in their UI.
- The returned `ImgproxyConfig` is frozen for safe sharing.
## Usage: Basic
```ts
import { parseConfig } from "@suaveplan/caddy-imgproxy";
const cfg = parseConfig({});
cfg.upstream; // "localhost:8080"
cfg.s3Bucket; // "images"
cfg.defaultWidth; // 800
```
## Usage: Advanced
```ts
import { parseConfig, ImgproxyConfigSchema } from "@suaveplan/caddy-imgproxy";
import z from "zod";
// Compose into a larger application schema:
const AppEnvSchema = z.object({
NODE_ENV: z.enum(["development", "production"]),
IMGPROXY: ImgproxyConfigSchema,
});
const env = AppEnvSchema.parse(process.env);
const cfg = parseConfig(env.IMGPROXY);
```
## API Reference
### `ImgproxyConfigSchema`
A Zod object schema with the following fields:
- `upstream: string` — default `"localhost:8080"`, non-empty.
- `s3Bucket: string` — default `"images"`, non-empty.
- `defaultWidth: number` — positive integer, default `800`.
- `key?: string` — optional even-length lower-hex string.
- `salt?: string` — optional even-length lower-hex string.
### `parseConfig(raw: unknown): ImgproxyConfig`
- **Returns:** a frozen `ImgproxyConfig`.
- **Throws:** `CaddyImgproxyError` with one of `INVALID_OPTIONS`,
`INVALID_KEY`, `INVALID_SALT`.
## Implementation Notes
`safeParse` is used instead of `parse` so the function can branch on
the first issue's `path` to choose the appropriate error code. The
schema lifts the hex check into a `refine` so the Zod issue path is
exactly `["key"]` or `["salt"]` when the hex format fails — that
makes the `parseConfig` dispatch one-liner reliable. The returned
config is frozen so downstream code cannot mutate it accidentally.

View File

@@ -0,0 +1,77 @@
# calculateDimensions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`calculateDimensions` resolves a `ParsedImgproxyUrl` plus the
configured `defaultWidth` into a concrete `{ width, height }` pair in
integer pixels. It is the single source of truth for how the compact
grammar's aspect-ratio + hint syntax becomes pixel values; every
processing string emitted by `operationToImgproxy` is derived from
this result.
Semantics mirror the upstream Go `calculateDimensions` byte-for-byte:
no dimensions → `{0, 0}` (passthrough); pixel dimensions return
verbatim; aspect-ratio with an `h` hint drives height first; otherwise
width drives (from the `w` hint or the configured default). All
divisions truncate toward zero via `Math.trunc` so the TypeScript and
Go implementations agree on every fixture.
## Features
- Total function — never throws, always returns concrete numbers.
- Honors the same precedence as the Go reference: hint axis wins over
default width.
- Integer truncation matches Go integer division exactly.
- Zero runtime cost beyond two multiplications and one divide on the
aspect path.
## Usage: Basic
```ts
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const dims = calculateDimensions(parsed, 800);
dims; // { width: 800, height: 450 }
```
## Usage: Advanced
```ts
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
// Height hint with portrait aspect ratio
const portrait = parseImgproxyUrl("/uv9:16h1080/photo.jpg");
calculateDimensions(portrait, 800);
// → { width: 607, height: 1080 }
// Pixel passthrough — the default-width parameter is ignored
const pixel = parseImgproxyUrl("/uf800x600/photo.jpg");
calculateDimensions(pixel, 9999);
// → { width: 800, height: 600 }
```
## API Reference
### `calculateDimensions(parsed: ParsedImgproxyUrl, defaultWidth: number): ImgproxyDimensions`
- **Parameters:**
- `parsed` — output of `parseImgproxyUrl`.
- `defaultWidth` — width used when the URL is in aspect-ratio mode
without a `w`/`h` hint.
- **Returns:** `{ width, height }`, both non-negative integers. Either
axis may be `0` (meaning "auto / unspecified").
## Implementation Notes
The function is intentionally a pure expression — it never touches
the clock, the meter, or the logger so it composes cleanly under
property-based tests. The default-width branch is reached when the
parsed URL specifies an aspect ratio but neither a `w` nor an `h`
hint, or when `hintType === "h"` but `hintValue === 0` (the
zero-value clause is unreachable from the public regex but defended
for callers who construct `ParsedImgproxyUrl` values directly).

View File

@@ -0,0 +1,67 @@
# extractDomain
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`extractDomain` translates an incoming HTTP `Host` header into the
S3 sub-path segment that the URL builder interpolates into the
`s3://<bucket>/<domain>/<path>@<format>` template. It is the single
place where the multi-tenant routing convention "the `images.` host
prefix maps to the bare apex domain" is enforced.
The function is total — it never throws — so callers may apply it
to any string. Hosts shorter than two labels (such as `localhost`)
are returned unchanged because the heuristic only makes sense for
fully-qualified domain names. Port suffixes are removed before
splitting so `images.example.com:8080` and `images.example.com` map
to the same value.
## Features
- Total function over arbitrary input strings.
- Port-aware: strips `:<port>` before label splitting.
- Single-label hosts (`localhost`) pass through unchanged.
- Mirrors the upstream Go heuristic exactly.
## Usage: Basic
```ts
import { extractDomain } from "@suaveplan/caddy-imgproxy";
extractDomain("images.example.com:8080"); // "example.com"
extractDomain("cdn.example.com"); // "cdn.example.com"
extractDomain("localhost"); // "localhost"
```
## Usage: Advanced
```ts
import { extractDomain } from "@suaveplan/caddy-imgproxy";
// Apply per-request to build a tenant key:
function tenantKey(host: string): string {
return extractDomain(host).replace(/\./g, "-");
}
tenantKey("images.acme.example.com"); // "acme-example-com"
```
## API Reference
### `extractDomain(host: string): string`
- **Parameters:** `host` — the raw `Host` header value (with or
without a port suffix).
- **Returns:** the apex domain when the prefix is `images.`,
otherwise the host unchanged (minus any port suffix).
## Implementation Notes
Implemented as a two-step string scan: `indexOf(":")` to locate the
optional port boundary, then `String.prototype.split(".")` to break
the host into labels. The label check requires at least two segments
because dropping the first label of a one-label host would yield the
empty string. The function does not validate the host against any
DNS spec — it accepts whatever the caller passes through.

View File

@@ -0,0 +1,101 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module is the canonical home of the package's
stable error-code registry and the typed `CaddyImgproxyError` class
that carries those codes. Every failure path in the package raises a
`CaddyImgproxyError` whose `code` is drawn from this registry;
consumers discriminate on `error.code` rather than `instanceof` so
the namespace survives module boundaries and runtime serialisation.
Codes are built with `@suaveplan/error`'s `defineCodes` helper, which
emits the canonical `NAMESPACE.REASON` form (e.g.
`"CADDY_IMGPROXY.INVALID_URL"`). The `CaddyImgproxyError` class
extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
against the literal union at compile time.
## Features
- Built via `defineCodes` so the namespace + reason form is uniform.
- `CaddyImgproxyError` carries `code`, `message`, `cause`, `context`,
`timestamp`, and the standard `SuaveplanError` envelope.
- Discriminating union literal (`CaddyImgproxyCode`) for exhaustive
`switch` checks.
- Every code in the registry is reachable from at least one call
site exercised by the tests.
## Usage: Basic
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "@suaveplan/caddy-imgproxy";
try {
// … call into the package
} catch (err) {
if (
err instanceof CaddyImgproxyError &&
err.code === CADDY_IMGPROXY_CODES.INVALID_URL
) {
// handle parse failure
}
}
```
## Usage: Advanced
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "@suaveplan/caddy-imgproxy";
function classify(err: unknown): "parse" | "config" | "host" | "other" {
if (!(err instanceof CaddyImgproxyError)) return "other";
switch (err.code) {
case CADDY_IMGPROXY_CODES.INVALID_URL:
case CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS:
return "parse";
case CADDY_IMGPROXY_CODES.INVALID_OPTIONS:
case CADDY_IMGPROXY_CODES.INVALID_KEY:
case CADDY_IMGPROXY_CODES.INVALID_SALT:
return "config";
case CADDY_IMGPROXY_CODES.INVALID_HOST:
return "host";
}
}
```
## API Reference
### `CADDY_IMGPROXY_CODES`
A frozen object mapping reason names to their `CADDY_IMGPROXY.REASON`
code strings. Reasons: `INVALID_URL`, `MISSING_DIMENSIONS`,
`INVALID_OPTIONS`, `INVALID_KEY`, `INVALID_SALT`, `INVALID_HOST`.
### `class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode>`
Public constructor: `(code, message, options?)`. Options carry
`cause` and `context`.
### `type CaddyImgproxyCode`
Discriminating union literal extracted from the registry values.
## Implementation Notes
`defineCodes` returns a frozen object indexed by reason name, with
each value typed as `${Namespace}.${Reason}` for compile-time literal
narrowing. The class deliberately exposes a public constructor —
`SuaveplanError`'s own constructor is `protected` so subclasses must
provide a concrete entry point. The `name = "CaddyImgproxyError"`
assignment matters for stack-trace clarity; without it `new.target`
would still set it correctly but explicit is clearer.

View File

@@ -0,0 +1,74 @@
# gravity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `gravity` module exposes the bidirectional translation between the
compact one- or two-character grammar codes (`t`, `tl`, `bl`, …) and
the two-/four-letter compass tokens that imgproxy itself consumes
(`no`, `nowe`, `sowe`, …). Every operation that takes a gravity
parameter — `cover`, `crop` — routes through this module so the
mapping lives in exactly one place.
The map is exported as a frozen `Readonly<Record<GravityCode, string>>`
so consumers may inspect or build their own translation tables on top
of it. The `gravityToImgproxy` helper handles unknown / empty input by
falling back to `"ce"` (center), matching the Go default branch
exactly.
## Features
- Total function — never throws.
- Single source of truth for the gravity grammar.
- Frozen map for safe sharing.
- Center default for forward-compatibility with grammar extensions.
## Usage: Basic
```ts
import { gravityToImgproxy } from "@suaveplan/caddy-imgproxy";
gravityToImgproxy("t"); // "no"
gravityToImgproxy("br"); // "soea"
gravityToImgproxy(""); // "ce" (default)
```
## Usage: Advanced
```ts
import { GRAVITY_MAP } from "@suaveplan/caddy-imgproxy";
// Build a reverse lookup for parsing imgproxy URLs back into the
// compact grammar.
const reverse = Object.fromEntries(
Object.entries(GRAVITY_MAP).map(([k, v]) => [v, k] as const)
);
reverse["sowe"]; // "bl"
```
## API Reference
### `gravityToImgproxy(code: string): string`
- **Parameters:** `code` — one of `t|b|l|r|c|tl|tr|bl|br`, or any
unknown value.
- **Returns:** the imgproxy compass token, or `"ce"` when input is
unknown or empty.
### `GRAVITY_MAP: Readonly<Record<GravityCode, string>>`
The frozen lookup table.
## Implementation Notes
The function uses an indexed object lookup rather than a `switch`
statement because the map is exposed as a frozen value; the default
branch handles both the empty-string and the unknown-code cases in a
single line. Coverage for the unknown branch is exercised by the test
`"defaults unknown codes to ce"`. The cast to `Record<string, string>`
inside the function is necessary because the formal type uses the
`GravityCode` union; at runtime any string may arrive (e.g. from a
caller-constructed `ParsedImgproxyUrl`).

View File

@@ -0,0 +1,121 @@
# ImgproxyHandler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ImgproxyHandler` is the high-level orchestrator that wraps
`parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl` into a
single `rewrite(request)` call suitable for use inside a CDN worker,
an edge reverse proxy, or a build-time tooling pass. It owns the
telemetry envelope around every rewrite, the structured logging on
parse failures, and the discriminated `RewriteResult` that lets
callers decide whether to forward to imgproxy, fall back, or return
an error to the client.
The handler exposes three observable outcomes: `rewritten` (the path
matched the grammar and a target imgproxy path was produced),
`passthrough` (the path is `/unsafe/...` or `/health*`, meaning the
caller should forward to imgproxy unchanged), and `invalid` (the
path is not an imgproxy URL and the caller should fall back to the
next handler in the chain).
## Features
- Injectable `Clock` for deterministic duration histograms.
- Injectable `LoggerLike` for structured parse-failure warnings.
- Injectable `SignerHashLike` for native / HSM / WebCrypto signing.
- Discriminated `RewriteResult` — no exceptions on the happy path.
- Telemetry: counter (`caddy_imgproxy_rewrite_total{outcome,
operation}`) + histogram (`caddy_imgproxy_rewrite_duration_ms`).
## Usage: Basic
```ts
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const handler = new ImgproxyHandler({
config: parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
}),
});
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
if (result.rewritten) {
forwardToImgproxy(result.targetPath);
} else if (result.reason === "passthrough") {
forwardToImgproxy(originalPath);
} else {
fallbackToNextHandler();
}
```
## Usage: Advanced
```ts
import type { Clock, LoggerLike } from "@suaveplan/types";
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const tick = createMonotonicTick();
const clock: Clock = () => tick.nowMs();
const logger: LoggerLike = {
debug: (m, c) => log("debug", m, c),
info: (m, c) => log("info", m, c),
warn: (m, c) => log("warn", m, c),
error: (m, c) => log("error", m, c),
};
const handler = new ImgproxyHandler({
config: parseConfig({}),
clock,
logger,
});
const result = handler.rewrite({
path: "/uvtr9:16h1080/hero.webp",
host: "images.example.com",
});
```
## API Reference
### `class ImgproxyHandler`
#### `new ImgproxyHandler(options: ImgproxyHandlerOptions)`
- `options.config` — validated `ImgproxyConfig`.
- `options.clock` — optional wall-clock; defaults to `Date.now`.
- `options.logger` — optional `LoggerLike`; defaults to a silent
no-op.
- `options.hash` — optional signer hash override.
#### `rewrite(request: RewriteRequest): RewriteResult`
Returns one of `{ rewritten: true; targetPath; operation }`,
`{ rewritten: false; reason: "passthrough" }`, or
`{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`.
### `RewriteRequest`
- `path` — incoming URL path including leading `/`.
- `host` — incoming `Host` header.
## Implementation Notes
Empty host is treated as a hard error (`INVALID_HOST`) rather than a
silent passthrough because the S3 path interpolation depends on the
host. `/unsafe/...` and `/health*` are detected only when
`parseImgproxyUrl` throws — the grammar deliberately does not match
these paths, so we treat the parse failure as a signal that this is a
direct imgproxy passthrough rather than a user-facing rewrite. Both
the clock and the logger are captured into instance fields at
construction time so a single handler is safe to share across many
requests in a long-lived server.

View File

@@ -0,0 +1,76 @@
# operationToImgproxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`operationToImgproxy` converts a resolved operation + concrete pixel
dimensions + gravity into the imgproxy processing-string segment that
sits between the `/<signature>/` prefix and the `/plain/s3://…@<fmt>`
suffix. It is the bridge between the compact grammar's abstract
operations and imgproxy's concrete URL DSL.
The five operations behave exactly as the upstream Go module: `cover`
and `crop` require a gravity prefix so anchoring takes effect during
cropping; `fit` ignores gravity (it always preserves aspect ratio);
`stretch` always emits `rs:force:W:H`; `resize` is the single-axis
variant of `fit`. Passthrough (`""`) and unknown operations emit an
empty string so the URL builder skips the processing segment entirely.
## Features
- Total function — every input returns a defined string, never
throws.
- Mirrors the Go switch table byte-for-byte.
- Single-axis fallbacks (`g:<g>/w:W`, `w:W`, `h:H`) so callers can
produce auto-axis resizes without re-implementing the table.
- Empty-string fallback drives the URL builder to skip the segment.
## Usage: Basic
```ts
import { operationToImgproxy } from "@suaveplan/caddy-imgproxy";
operationToImgproxy("cover", 1920, 1080, "t");
// → "g:no/rs:fill:1920:1080"
operationToImgproxy("fit", 800, 0, "c");
// → "w:800"
```
## Usage: Advanced
```ts
import {
calculateDimensions,
operationToImgproxy,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
const { width, height } = calculateDimensions(parsed, 800);
operationToImgproxy(parsed.operation, width, height, parsed.gravity);
// → "g:sowe/c:1920:1440"
```
## API Reference
### `operationToImgproxy(op: ImgproxyOperation, width: number, height: number, gravity: string): string`
- **Parameters:**
- `op` — one of `""|cover|crop|fit|stretch|resize`.
- `width`, `height` — non-negative integers; either may be zero.
- `gravity` — a grammar code accepted by `gravityToImgproxy`.
- **Returns:** the imgproxy processing string, or `""` when the
operation cannot be expressed (e.g. `crop` without both dims).
## Implementation Notes
The switch is exhaustive over the `ImgproxyOperation` union; the
TypeScript compiler verifies no case escapes. Gravity is resolved
once at the top of the function so the `cover`/`crop` branches share
a single translation step. The empty-operation case returns `""`
explicitly rather than falling through so the exhaustiveness check
stays clean.

View File

@@ -0,0 +1,98 @@
# parseImgproxyUrl
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseImgproxyUrl` parses the compact imgproxy URL grammar used by the
Caddy module — `/[s|u]<op><gravity><dims><modifiers>/<path>.<format>` —
into a typed, frozen `ParsedImgproxyUrl`. It is the canonical entry
point for every other helper in the package; the URL builder, the
handler, and the dimensions calculator all consume the value it
returns.
Behaviour mirrors the upstream Go implementation byte-for-byte so the
TypeScript port and the Go module can be tested against a shared
fixture corpus. The function never partially succeeds: invalid input
throws a `CaddyImgproxyError` carrying a stable code from
`CADDY_IMGPROXY_CODES`, and the parse-total counter ticks once per
call with the matching outcome tag.
## Features
- Single-pass regex grammar covering signed / unsigned prefix, five
operations (`cover`, `crop`, `fit`, `stretch`, `resize`), nine
gravities, aspect / pixel / square dimensions, and width / height /
quality modifiers in arbitrary order.
- Quality validation built into the grammar (`q1..q100`) — out-of-band
values raise `INVALID_URL`.
- Operation + dimension constraint enforcement (`MISSING_DIMENSIONS`).
- Frozen, immutable return value safe to memoise.
- OTel counter on every call (`caddy_imgproxy_parse_total`).
## Usage: Basic
```ts
import { parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
parsed.operation; // "cover"
parsed.aspectW; // 16
parsed.path; // "avatar"
parsed.format; // "png"
```
## Usage: Advanced
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
function tryParse(path: string): string | null {
try {
const parsed = parseImgproxyUrl(path);
return `${parsed.operation || "passthrough"} → ${parsed.path}.${parsed.format}`;
} catch (err) {
if (
err instanceof CaddyImgproxyError &&
err.code === CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
) {
return "needs dims";
}
throw err;
}
}
```
## API Reference
### `parseImgproxyUrl(path: string): ParsedImgproxyUrl`
- **Parameters:** `path` — the URL path including leading `/`.
- **Returns:** a frozen `ParsedImgproxyUrl` (see `types/types.ts`).
- **Throws:**
- `CaddyImgproxyError(INVALID_URL)` when the regex rejects the
input — bad prefix, malformed dimensions, quality outside
`1..100`.
- `CaddyImgproxyError(MISSING_DIMENSIONS)` when an operation
(`v|c|f|s|r`) is present without any dimension grammar.
## Implementation Notes
The grammar regex is a single anchored expression with thirteen
capture groups that mirror the field order of the Go regex
exactly. The modifier sub-grammar (`w<int>`, `h<int>`, `q1..100`) is
extracted via a global secondary regex so order is irrelevant — the
last `w`/`h` wins (matching Go), and `q` always wins for quality.
`Number.parseInt` with radix 10 is used for every numeric extraction
to avoid the legacy octal-from-leading-zero behaviour. Defensive
defaults are deliberately omitted from branches the regex can never
reach so coverage stays honest; the only fallbacks in the function
body protect against future grammar evolution (e.g. an empty gravity
capture defaulting to `"c"`).

View File

@@ -0,0 +1,95 @@
# signImgproxyPath
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`signImgproxyPath` computes the HMAC-SHA256 URL signature that
imgproxy requires when it is configured with a key + salt. It is the
single source of truth for how the TypeScript port and the upstream
Go module agree on bytes: any URL signed here verifies against the Go
implementation and vice-versa.
The function decodes the hex-encoded `key` and `salt` into raw bytes,
concatenates the salt with the UTF-8 encoded `path`, runs HMAC-SHA256
under the key, and base64url-encodes the digest without padding. Bad
hex on either input raises a typed `CaddyImgproxyError`
(`INVALID_KEY` / `INVALID_SALT`) so misconfiguration surfaces
immediately rather than at the imgproxy boundary.
## Features
- Verbatim byte-level port of the Go `signPath` function.
- Pluggable hash via the optional `hash` parameter — supply a native
/ HSM / WebCrypto-backed implementation when needed.
- Hex validation stricter than `@suaveplan/crypto-hash`'s `fromHex`
(lower-case only) to keep wire-format round-trips deterministic.
- Throws typed `CaddyImgproxyError` on every failure path.
## Usage: Basic
```ts
import { signImgproxyPath } from "@suaveplan/caddy-imgproxy";
const signature = signImgproxyPath({
key: "aa11bbcc",
salt: "deadbeef",
path: "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png",
});
signature; // "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U"
```
## Usage: Advanced
```ts
import { signImgproxyPath, type SignerHashLike } from "@suaveplan/caddy-imgproxy";
// Plug a WebCrypto-backed signer in a Cloudflare Worker:
const wcHash: SignerHashLike = {
hmac(_algo, key, data) {
// ... call into crypto.subtle.sign synchronously via a vendored
// implementation, return the digest as Uint8Array.
return data;
},
};
const sig = signImgproxyPath({
key: "deadbeef",
salt: "feedface",
path: "/plain/s3://images/example.com/photo@webp",
hash: wcHash,
});
```
## API Reference
### `signImgproxyPath(options: SignImgproxyPathOptions): string`
- **Parameters:**
- `options.key` — lower-hex HMAC key, even length, non-empty.
- `options.salt` — lower-hex salt, even length, non-empty.
- `options.path` — the imgproxy path being signed.
- `options.hash` — optional `SignerHashLike` injection seam.
- **Returns:** base64url-encoded signature, no padding.
- **Throws:**
- `CaddyImgproxyError(INVALID_KEY)` for malformed / empty / odd-length key.
- `CaddyImgproxyError(INVALID_SALT)` for malformed / empty / odd-length salt.
### `SignerHashLike`
Structural type with a single `hmac(algorithm, key, data)` method
returning a `Uint8Array`. Lets callers swap the default Noble-backed
implementation for a native, HSM, or WebCrypto-backed alternative.
## Implementation Notes
The function pre-allocates the `salt || path` buffer in one
`Uint8Array(saltBytes.length + pathBytes.length)` allocation so the
hot path never reallocates. UTF-8 encoding goes through
`TextEncoder`, which is available in every modern JS runtime
(Node ≥ 14, all browsers, Cloudflare Workers, Deno, Bun). The Go
reference uses `[]byte(path)` which is identical for ASCII paths and
spec-compliant for arbitrary UTF-8 (the imgproxy upstream uses the
same convention).

View File

@@ -0,0 +1,89 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `telemetry` module owns the OTel instruments emitted by every
public entry point in `@suaveplan/caddy-imgproxy`. It declares three
counters and one histogram, registers them against a single meter
named `@suaveplan/caddy-imgproxy`, and exposes thin `record*`
helpers so the consuming feature modules never need to import the
raw OTel API.
When no `MeterProvider` is registered (the default in unit tests and
production code that has not opted into telemetry), the OTel API
returns a no-op meter and the helpers become no-ops. This makes the
instruments safe to import unconditionally — the package never
forces a runtime cost on consumers who do not want telemetry.
## Features
- Three counters: parse outcomes, rewrite outcomes (tagged with
operation), sign mode.
- One histogram: rewrite duration in milliseconds.
- Helpers (`recordParseOutcome`, `recordRewriteOutcome`,
`recordSignMode`, `recordRewriteDurationMs`) so feature code stays
free of OTel boilerplate.
- Discriminated tag literals (`ParseOutcome`, `RewriteOutcome`,
`SignMode`) for compile-time safety on tag values.
## Usage: Basic
```ts
import { recordParseOutcome, recordSignMode } from "@suaveplan/caddy-imgproxy";
recordParseOutcome("ok");
recordSignMode("signed");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { rewriteDurationHistogram } from "@suaveplan/caddy-imgproxy";
// Wire a real MeterProvider before importing the rest of the package
// so the histogram is bound to an active exporter.
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
// rewriteDurationHistogram now records into the live provider.
expect(rewriteDurationHistogram).toBeDefined();
```
## API Reference
### Counters
- `parseTotalCounter` — name `caddy_imgproxy_parse_total`, tagged
with `outcome` ∈ `ok | invalid | missing_dims`.
- `rewriteTotalCounter` — `caddy_imgproxy_rewrite_total`, tagged
with `outcome` ∈ `rewritten | passthrough | invalid` and
`operation`.
- `signTotalCounter` — `caddy_imgproxy_sign_total`, tagged with
`mode` ∈ `signed | unsafe`.
### Histogram
- `rewriteDurationHistogram` — `caddy_imgproxy_rewrite_duration_ms`,
unit `ms`.
### Helpers
- `recordParseOutcome(outcome)`
- `recordRewriteOutcome(outcome, operation)`
- `recordSignMode(mode)`
- `recordRewriteDurationMs(durationMs)`
## Implementation Notes
The meter is bound at module import time via `metrics.getMeter(...)`.
When no provider is registered the OTel API returns a no-op meter,
so the instrument constructors do not throw. Helpers funnel through
the counters/histogram via the standard `add(value, tags)` /
`record(value)` API; the tag object literal is constructed inline
each call so V8 can inline it into the call site under hot loops.

View File

@@ -0,0 +1,98 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module declares every cross-feature type that flows
between the package's runtime modules: `ParsedImgproxyUrl` (the
output of the parser), `ImgproxyConfig` (the public configuration
shape), `ImgproxyDimensions` (the resolved pixel pair), and the
discriminated `RewriteResult` union that `ImgproxyHandler.rewrite`
returns. Centralising them here keeps the package's contract in one
place and prevents circular imports between feature modules.
These types are runtime-free — `types.ts` has no executable code and
is excluded from coverage by the vitest config. Consumers that only
want the type contract can import from `@suaveplan/caddy-imgproxy/types`
without pulling in any runtime dependency.
## Features
- Single source of truth for the package's data contracts.
- Discriminated union (`RewriteResult`) for branchless handling of
the three rewrite outcomes.
- Frozen-by-convention return values throughout the package — the
types use `readonly` modifiers so accidental mutation is a
compile-time error.
- Closed unions (`ImgproxyOperation`, `GravityCode`, `HintType`)
give the rest of the package exhaustive `switch` coverage.
## Usage: Basic
```ts
import type { ImgproxyConfig, ParsedImgproxyUrl } from "@suaveplan/caddy-imgproxy";
function describe(parsed: ParsedImgproxyUrl, config: ImgproxyConfig): string {
return `${parsed.operation || "passthrough"} for ${config.s3Bucket}`;
}
```
## Usage: Advanced
```ts
import type { RewriteResult } from "@suaveplan/caddy-imgproxy";
function summarise(result: RewriteResult): string {
if (result.rewritten) return `→ ${result.targetPath}`;
if (result.reason === "passthrough") return "passthrough";
return `invalid (${result.errorCode}): ${result.errorMessage}`;
}
```
## API Reference
### `ImgproxyOperation`
Union of `""|cover|crop|fit|stretch|resize`.
### `GravityCode`
Union of `t|b|l|r|c|tl|tr|bl|br`.
### `HintType`
Union of `""|w|h`.
### `ParsedImgproxyUrl`
Frozen, immutable parser output. Carries `signed`, `operation`,
`gravity`, `aspectW`, `aspectH`, `pixelW`, `pixelH`, `hintType`,
`hintValue`, `quality`, `path`, `format`, `isAspect`, `hasDims`.
### `ImgproxyConfig`
Validated configuration carrying `upstream`, `s3Bucket`,
`defaultWidth`, and optional `key` / `salt`.
### `ImgproxyDimensions`
`{ width: number; height: number }` — output of
`calculateDimensions`.
### `RewriteResult`
Discriminated union of `RewriteRewrittenResult`,
`RewritePassthroughResult`, and `RewriteInvalidResult`.
## Implementation Notes
Every field uses `readonly` to communicate immutability through the
type. The discriminated `RewriteResult` shape is deliberately tagged
twice (`rewritten` for the boolean discriminator and `reason` for
the failure sub-discriminator) so consumers can branch on either
side without losing TypeScript narrowing. The optional `key` / `salt`
on `ImgproxyConfig` carry an explicit `| undefined` because the
package is built under `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,92 @@
# buildImgproxyUrl
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`buildImgproxyUrl` composes the final imgproxy URL path from a parsed
grammar representation. It is the orchestrator that ties together
`calculateDimensions`, `operationToImgproxy`, and `signImgproxyPath`
into the single string a downstream proxy or CDN edge needs to
forward an image-rewrite request to imgproxy.
The function mirrors the upstream Go `buildImgproxyURL` semantics in
every detail: processing string first, optional `q:<quality>` next,
then the `plain/s3://<bucket>/<domain>/<path>@<format>` payload, and
finally either a `/<base64url-signature>` prefix (when the config
carries both a key and a salt) or `/unsafe`. Every call records a
single `caddy_imgproxy_sign_total` sample tagged with the mode.
## Features
- Single source of truth for the URL composition order.
- Telemetry on every call (sign-mode counter).
- Honours the empty-string fallbacks from `operationToImgproxy` —
passthrough URLs produce a clean `/unsafe/plain/...` shape.
- Supports the injectable signer hash from `signImgproxyPath`.
## Usage: Basic
```ts
import {
buildImgproxyUrl,
parseConfig,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({ defaultWidth: 800 });
buildImgproxyUrl({ parsed, domain: "example.com", config });
// → "/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
```
## Usage: Advanced
```ts
import {
buildImgproxyUrl,
parseConfig,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const config = parseConfig({
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11bbcc",
salt: "deadbeef",
});
// Quality + width hint + signed
const parsed = parseImgproxyUrl("/uv16:9q90w1920/photo.png");
const url = buildImgproxyUrl({ parsed, domain: "example.com", config });
// → "/<signature>/g:ce/rs:fill:1920:1080/q:90/plain/s3://media/example.com/photo@png"
```
## API Reference
### `buildImgproxyUrl(options: BuildImgproxyUrlOptions): string`
- **Parameters:**
- `options.parsed` — output of `parseImgproxyUrl`.
- `options.domain` — virtual-host segment for the S3 path.
- `options.config` — validated `ImgproxyConfig`.
- `options.hash` — optional signer hash override.
- **Returns:** the full imgproxy path including the signature prefix.
### `BuildImgproxyUrlOptions`
Inputs interface — see above.
## Implementation Notes
Signature mode is selected only when BOTH `config.key` and
`config.salt` are non-empty strings. An empty string in either slot
falls back to `/unsafe` so callers can disable signing at runtime
without re-parsing the config. The `caddy_imgproxy_sign_total`
counter is incremented exactly once per call — either as `signed` or
as `unsafe` — even when the upstream signer throws (the throw
propagates after the counter increments via the signer's own error
path, since `signImgproxyPath` raises before this function records).