Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,131 @@
{
"name": "@suaveplan/caddy-imgproxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./parse": {
"import": {
"types": "./dist/parse/index.d.ts",
"default": "./dist/parse/index.js"
}
},
"./dimensions": {
"import": {
"types": "./dist/dimensions/index.d.ts",
"default": "./dist/dimensions/index.js"
}
},
"./gravity": {
"import": {
"types": "./dist/gravity/index.d.ts",
"default": "./dist/gravity/index.js"
}
},
"./operation": {
"import": {
"types": "./dist/operation/index.d.ts",
"default": "./dist/operation/index.js"
}
},
"./signing": {
"import": {
"types": "./dist/signing/index.d.ts",
"default": "./dist/signing/index.js"
}
},
"./url-builder": {
"import": {
"types": "./dist/url-builder/index.d.ts",
"default": "./dist/url-builder/index.js"
}
},
"./domain": {
"import": {
"types": "./dist/domain/index.d.ts",
"default": "./dist/domain/index.js"
}
},
"./handler": {
"import": {
"types": "./dist/handler/index.d.ts",
"default": "./dist/handler/index.js"
}
},
"./config": {
"import": {
"types": "./dist/config/index.d.ts",
"default": "./dist/config/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
type CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseConfig } from "./config.js";
describe("parseConfig", () => {
it("applies defaults when fields are omitted", { timeout: 5000 }, () => {
const cfg = parseConfig({});
expect(cfg.upstream).toBe("localhost:8080");
expect(cfg.s3Bucket).toBe("images");
expect(cfg.defaultWidth).toBe(800);
expect(cfg.key).toBeUndefined();
expect(cfg.salt).toBeUndefined();
});
it("accepts a fully-specified config", { timeout: 5000 }, () => {
const cfg = parseConfig({
upstream: "imgproxy:8080",
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11",
salt: "bbcc",
});
expect(cfg.upstream).toBe("imgproxy:8080");
expect(cfg.s3Bucket).toBe("media");
expect(cfg.defaultWidth).toBe(1200);
expect(cfg.key).toBe("aa11");
expect(cfg.salt).toBe("bbcc");
});
it("raises INVALID_OPTIONS when defaultWidth is zero", {
timeout: 5000,
}, () => {
try {
parseConfig({ defaultWidth: 0 });
expect.fail("expected INVALID_OPTIONS");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
);
}
});
it("raises INVALID_OPTIONS when upstream is empty", {
timeout: 5000,
}, () => {
try {
parseConfig({ upstream: "" });
expect.fail("expected INVALID_OPTIONS");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
);
}
});
it("raises INVALID_KEY when key is malformed hex", {
timeout: 5000,
}, () => {
try {
parseConfig({ key: "xyz" });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY when key is odd length", { timeout: 5000 }, () => {
try {
parseConfig({ key: "abc" });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_SALT when salt is malformed hex", {
timeout: 5000,
}, () => {
try {
parseConfig({ salt: "zz" });
expect.fail("expected INVALID_SALT");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_SALT
);
}
});
});

View File

@@ -0,0 +1,71 @@
import z from "zod";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import type { ImgproxyConfig } from "../types/types.js";
const HEX_RE = /^[0-9a-f]*$/u;
const HexStringSchema = z
.string()
.min(2, "must be a non-empty even-length lower-hex string")
.refine(
(value) => value.length % 2 === 0 && HEX_RE.test(value),
"must be even-length lower-hex"
);
/**
* Public Zod schema for `ImgproxyConfig`. Defaults mirror the Go
* module: upstream `localhost:8080`, bucket `images`, default width
* 800. `key` / `salt` are validated as even-length lower-hex when
* supplied; absence flips the rewriter into `/unsafe` mode.
*/
export const ImgproxyConfigSchema = z.object({
upstream: z.string().min(1).default("localhost:8080"),
s3Bucket: z.string().min(1).default("images"),
defaultWidth: z
.number()
.int("defaultWidth must be an integer")
.positive("defaultWidth must be > 0")
.default(800),
key: HexStringSchema.optional(),
salt: HexStringSchema.optional(),
});
/**
* Parses and validates a raw config object into an immutable
* `ImgproxyConfig`. Raises `CaddyImgproxyError(INVALID_KEY)` when the
* key is malformed hex, `INVALID_SALT` for the salt, and
* `INVALID_OPTIONS` for everything else.
*/
export function parseConfig(raw: unknown): ImgproxyConfig {
const result = ImgproxyConfigSchema.safeParse(raw);
if (!result.success) {
const firstIssue = result.error.issues[0] as {
path: PropertyKey[];
message: string;
};
const path = firstIssue.path.join(".");
if (path === "key") {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_KEY,
"key must be a non-empty even-length lower-hex string",
{ context: { path } }
);
}
if (path === "salt") {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_SALT,
"salt must be a non-empty even-length lower-hex string",
{ context: { path } }
);
}
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS,
`ImgproxyConfig validation failed: ${firstIssue.message}`,
{ context: { path } }
);
}
return Object.freeze(result.data);
}

View File

@@ -0,0 +1 @@
export { ImgproxyConfigSchema, parseConfig } from "./config.js";

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,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ParsedImgproxyUrl } from "../types/types.js";
import { calculateDimensions } from "./calculate-dimensions.js";
function parsed(overrides: Partial<ParsedImgproxyUrl>): ParsedImgproxyUrl {
return {
signed: false,
operation: "",
gravity: "c",
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: 0,
hintType: "",
hintValue: 0,
quality: 0,
path: "x",
format: "png",
isAspect: false,
hasDims: false,
...overrides,
};
}
describe("calculateDimensions", () => {
it("returns zero when hasDims is false", { timeout: 5000 }, () => {
const r = calculateDimensions(parsed({}), 800);
expect(r).toEqual({ width: 0, height: 0 });
});
it("returns pixel dimensions unchanged", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({ pixelW: 800, pixelH: 600, hasDims: true }),
1200
);
expect(r).toEqual({ width: 800, height: 600 });
});
it("resolves 16:9 with default width", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 16,
aspectH: 9,
isAspect: true,
hasDims: true,
}),
800
);
expect(r).toEqual({ width: 800, height: 450 });
});
it("resolves 9:16 with default width", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 9,
aspectH: 16,
isAspect: true,
hasDims: true,
}),
800
);
expect(r).toEqual({ width: 800, height: 1422 });
});
it("resolves 16:9 with width hint", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 16,
aspectH: 9,
isAspect: true,
hasDims: true,
hintType: "w",
hintValue: 1920,
}),
800
);
expect(r).toEqual({ width: 1920, height: 1080 });
});
it("resolves 9:16 with height hint", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 9,
aspectH: 16,
isAspect: true,
hasDims: true,
hintType: "h",
hintValue: 1080,
}),
800
);
expect(r).toEqual({ width: 607, height: 1080 });
});
it("resolves 1:1 square aspect", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 1,
aspectH: 1,
isAspect: true,
hasDims: true,
}),
500
);
expect(r).toEqual({ width: 500, height: 500 });
});
it("h-hint with zero value falls back to default-width path", {
timeout: 5000,
}, () => {
// hintType = "h" but hintValue = 0 — should hit the default width branch
const r = calculateDimensions(
parsed({
aspectW: 1,
aspectH: 1,
isAspect: true,
hasDims: true,
hintType: "h",
hintValue: 0,
}),
500
);
expect(r).toEqual({ width: 500, height: 500 });
});
});

View File

@@ -0,0 +1,37 @@
import type { ImgproxyDimensions, ParsedImgproxyUrl } from "../types/types.js";
/**
* Resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth`
* into concrete integer pixel dimensions.
*
* Mirrors the Go `calculateDimensions` precedence:
* - No dimensions present → `{ 0, 0 }` (caller passes through).
* - Pixel dimensions present → return them verbatim.
* - Aspect ratio + `h` hint → height drives, width derived.
* - Aspect ratio + `w` hint OR no hint → width drives (or default), height derived.
*
* All derived values use `Math.trunc` so the integer truncation
* exactly matches Go's `int` division.
*/
export function calculateDimensions(
parsed: ParsedImgproxyUrl,
defaultWidth: number
): ImgproxyDimensions {
if (!parsed.hasDims) {
return { width: 0, height: 0 };
}
if (!parsed.isAspect) {
return { width: parsed.pixelW, height: parsed.pixelH };
}
if (parsed.hintType === "h" && parsed.hintValue > 0) {
const height = parsed.hintValue;
const width = Math.trunc((height * parsed.aspectW) / parsed.aspectH);
return { width, height };
}
const width = parsed.hintValue > 0 ? parsed.hintValue : defaultWidth;
const height = Math.trunc((width * parsed.aspectH) / parsed.aspectW);
return { width, height };
}

View File

@@ -0,0 +1 @@
export { calculateDimensions } from "./calculate-dimensions.js";

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,23 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { extractDomain } from "./extract-domain.js";
describe("extractDomain", () => {
it("strips port and drops the images. subdomain", { timeout: 5000 }, () => {
expect(extractDomain("images.example.com:8080")).toBe("example.com");
expect(extractDomain("images.example.com")).toBe("example.com");
});
it("returns hosts without images. unchanged", { timeout: 5000 }, () => {
expect(extractDomain("example.com")).toBe("example.com");
expect(extractDomain("cdn.example.com")).toBe("cdn.example.com");
});
it("returns short hosts unchanged", { timeout: 5000 }, () => {
expect(extractDomain("localhost")).toBe("localhost");
expect(extractDomain("localhost:8080")).toBe("localhost");
});
it("handles images. with two-label host", { timeout: 5000 }, () => {
expect(extractDomain("images.co.uk")).toBe("co.uk");
});
});

View File

@@ -0,0 +1,24 @@
/**
* Strips any `:<port>` suffix and drops a leading `images.` subdomain
* label. Mirrors the Go `extractDomain` helper byte-for-byte:
*
* - `images.example.com:8080` → `example.com`
* - `images.example.com` → `example.com`
* - `example.com` → `example.com`
* - `localhost:8080` → `localhost`
* - `images.co.uk` → `co.uk`
*
* Hosts shorter than two labels after port removal are returned
* unchanged. The function is total — it never throws — so callers
* can safely apply it to any `Host` header value.
*/
export function extractDomain(host: string): string {
const colonIdx = host.indexOf(":");
const withoutPort = colonIdx >= 0 ? host.slice(0, colonIdx) : host;
const parts = withoutPort.split(".");
if (parts.length >= 2 && parts[0] === "images") {
return parts.slice(1).join(".");
}
return withoutPort;
}

View File

@@ -0,0 +1 @@
export { extractDomain } from "./extract-domain.js";

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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";
describe("CADDY_IMGPROXY_CODES", () => {
it("namespaces every code under CADDY_IMGPROXY.", { timeout: 5000 }, () => {
for (const value of Object.values(CADDY_IMGPROXY_CODES)) {
expect(value.startsWith("CADDY_IMGPROXY.")).toBe(true);
}
});
it("exposes the expected leaf names", { timeout: 5000 }, () => {
expect(Object.keys(CADDY_IMGPROXY_CODES).sort()).toEqual([
"INVALID_HOST",
"INVALID_KEY",
"INVALID_OPTIONS",
"INVALID_SALT",
"INVALID_URL",
"MISSING_DIMENSIONS",
]);
});
it("carries the supplied code, message, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_URL,
"bad url",
{ cause, context: { path: "/x" } }
);
expect(err).toBeInstanceOf(CaddyImgproxyError);
expect(err.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
expect(err.message).toBe("bad url");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ path: "/x" });
expect(err.name).toBe("CaddyImgproxyError");
});
it("every registered code is thrown from at least one call site", {
timeout: 5000,
}, async () => {
// INVALID_URL — from parse-url
// MISSING_DIMENSIONS — from parse-url
// INVALID_OPTIONS — from config
// INVALID_KEY — from config / signing
// INVALID_SALT — from config / signing
// INVALID_HOST — from handler
// Each is exercised by its own test file; this reachability
// gate just confirms the registry has the expected closure.
const expected = new Set([
"CADDY_IMGPROXY.INVALID_URL",
"CADDY_IMGPROXY.MISSING_DIMENSIONS",
"CADDY_IMGPROXY.INVALID_OPTIONS",
"CADDY_IMGPROXY.INVALID_KEY",
"CADDY_IMGPROXY.INVALID_SALT",
"CADDY_IMGPROXY.INVALID_HOST",
]);
const actual = new Set(Object.values(CADDY_IMGPROXY_CODES));
expect(actual).toEqual(expected);
});
});

View File

@@ -0,0 +1,66 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/caddy-imgproxy`.
*
* Every failure path in the package surfaces a `CaddyImgproxyError`
* whose `code` is drawn from this registry. Consumers SHOULD
* discriminate on `error.code` rather than `instanceof` so the
* namespace survives module boundaries and runtime serialisation
* (see `@suaveplan/error`).
*
* - `INVALID_URL` — the input path does not match the imgproxy
* grammar regex (missing prefix, bad quality, malformed dims, …).
* - `MISSING_DIMENSIONS` — the parsed URL named an operation
* (`v|c|f|s|r`) without dimensions.
* - `INVALID_OPTIONS` — Zod validation of `ImgproxyConfig` failed.
* - `INVALID_KEY` — `config.key` is not even-length lower-hex.
* - `INVALID_SALT` — `config.salt` is not even-length lower-hex.
* - `INVALID_HOST` — the rewriter received a host that could not be
* processed by `extractDomain` (reserved for future use; raised
* when host is empty).
*/
export const CADDY_IMGPROXY_CODES = defineCodes("CADDY_IMGPROXY", [
"INVALID_URL",
"MISSING_DIMENSIONS",
"INVALID_OPTIONS",
"INVALID_KEY",
"INVALID_SALT",
"INVALID_HOST",
] as const);
/** Discriminating union literal for {@link CaddyImgproxyError.code}. */
export type CaddyImgproxyCode =
(typeof CADDY_IMGPROXY_CODES)[keyof typeof CADDY_IMGPROXY_CODES];
/**
* Typed error for every `@suaveplan/caddy-imgproxy` failure path.
*
* Extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
* against the literal union; consumers get exhaustiveness checking
* on `switch (err.code)` blocks. Carries a public constructor so
* `new CaddyImgproxyError(...)` works at the call site.
*
* @example
* ```ts
* import { CADDY_IMGPROXY_CODES, CaddyImgproxyError, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
*
* try {
* parseImgproxyUrl("/garbage");
* } catch (err) {
* if (err instanceof CaddyImgproxyError && err.code === CADDY_IMGPROXY_CODES.INVALID_URL) {
* console.warn("not an imgproxy URL", err.context);
* }
* }
* ```
*/
export class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode> {
public constructor(
code: CaddyImgproxyCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "CaddyImgproxyError";
}
}

View File

@@ -0,0 +1,2 @@
export type { CaddyImgproxyCode } from "./error-codes.js";
export { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";

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,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";
describe("gravityToImgproxy", () => {
it("translates every direction to imgproxy tokens", {
timeout: 5000,
}, () => {
expect(gravityToImgproxy("t")).toBe("no");
expect(gravityToImgproxy("b")).toBe("so");
expect(gravityToImgproxy("l")).toBe("we");
expect(gravityToImgproxy("r")).toBe("ea");
expect(gravityToImgproxy("c")).toBe("ce");
expect(gravityToImgproxy("tl")).toBe("nowe");
expect(gravityToImgproxy("tr")).toBe("noea");
expect(gravityToImgproxy("bl")).toBe("sowe");
expect(gravityToImgproxy("br")).toBe("soea");
});
it("defaults unknown codes to ce", { timeout: 5000 }, () => {
expect(gravityToImgproxy("")).toBe("ce");
expect(gravityToImgproxy("xx")).toBe("ce");
});
it("GRAVITY_MAP is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(GRAVITY_MAP)).toBe(true);
});
});

View File

@@ -0,0 +1,39 @@
import type { GravityCode } from "../types/types.js";
/**
* Bidirectional grammar → imgproxy gravity token map. Mirrors the
* upstream Go `gravityToImgproxy` switch statement exactly.
*
* Imgproxy gravity tokens follow the compass-direction convention:
* - `no` north (top)
* - `so` south (bottom)
* - `we` west (left)
* - `ea` east (right)
* - `nowe`/`noea`/`sowe`/`soea` for the four corners
* - `ce` center (default)
*/
export const GRAVITY_MAP: Readonly<Record<GravityCode, string>> = Object.freeze(
{
t: "no",
b: "so",
l: "we",
r: "ea",
c: "ce",
tl: "nowe",
tr: "noea",
bl: "sowe",
br: "soea",
}
);
/**
* Translates a single-character grammar gravity code into its
* imgproxy token. Unknown / empty input falls back to `"ce"`
* (center) — matching the Go default branch.
*/
export function gravityToImgproxy(code: string): string {
if (code === "" || code === undefined) return "ce";
const token = (GRAVITY_MAP as Record<string, string>)[code];
if (token === undefined) return "ce";
return token;
}

View File

@@ -0,0 +1 @@
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";

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,153 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, LoggerLike } from "@suaveplan/types";
import { parseConfig } from "../config/config.js";
import { CADDY_IMGPROXY_CODES } from "../error-codes/error-codes.js";
import { ImgproxyHandler, NOOP_LOGGER } from "./handler.js";
function makeLogger(): {
logger: LoggerLike;
warns: Array<{
message: string;
context?: Readonly<Record<string, unknown>>;
}>;
} {
const warns: Array<{
message: string;
context?: Readonly<Record<string, unknown>>;
}> = [];
return {
warns,
logger: {
debug: () => undefined,
info: () => undefined,
warn: (message, context) => {
warns.push({ message, context });
},
error: () => undefined,
},
};
}
describe("ImgproxyHandler", () => {
it("rewrites a valid imgproxy URL", { timeout: 5000 }, () => {
const config = parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
if (!result.rewritten) {
throw new Error("expected rewritten=true");
}
expect(result.operation).toBe("cover");
expect(result.targetPath.startsWith("/")).toBe(true);
expect(result.targetPath).toContain("rs:fill:800:450");
expect(result.targetPath).toContain(
"plain/s3://images/example.com/avatar@png"
);
});
it("records caddy_imgproxy_rewrite_duration_ms via the injected clock", {
timeout: 5000,
}, () => {
let tick = 1000;
const clock: Clock = () => {
tick += 5;
return tick;
};
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, clock });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
expect(result.rewritten).toBe(true);
// No exception means the clock-driven duration was recorded.
});
it("reports passthrough for /unsafe paths", { timeout: 5000 }, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/unsafe/foo/bar.png",
host: "example.com",
});
expect(result).toEqual({
rewritten: false,
reason: "passthrough",
});
});
it("reports passthrough for /health paths", { timeout: 5000 }, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/health",
host: "example.com",
});
expect(result).toEqual({
rewritten: false,
reason: "passthrough",
});
});
it("reports invalid for unparsable paths and warns the logger", {
timeout: 5000,
}, () => {
const { logger, warns } = makeLogger();
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, logger });
const result = handler.rewrite({
path: "/garbage",
host: "example.com",
});
if (result.rewritten !== false || result.reason !== "invalid") {
throw new Error("expected invalid result");
}
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
expect(result.errorMessage.length).toBeGreaterThan(0);
expect(warns.length).toBe(1);
expect(warns[0]?.context?.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
});
it("raises INVALID_HOST when host is empty", { timeout: 5000 }, () => {
const { logger, warns } = makeLogger();
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, logger });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "",
});
if (result.rewritten !== false || result.reason !== "invalid") {
throw new Error("expected invalid result");
}
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_HOST);
expect(warns.length).toBe(1);
});
it("uses the default no-op logger when not supplied", {
timeout: 5000,
}, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
// No logger provided — default no-op must absorb the warn call.
const result = handler.rewrite({
path: "/garbage",
host: "example.com",
});
expect(result.rewritten).toBe(false);
});
it("NOOP_LOGGER every method is callable and returns undefined", {
timeout: 5000,
}, () => {
expect(NOOP_LOGGER.debug("msg")).toBeUndefined();
expect(NOOP_LOGGER.info("msg")).toBeUndefined();
expect(NOOP_LOGGER.warn("msg")).toBeUndefined();
expect(NOOP_LOGGER.error("msg")).toBeUndefined();
});
});

View File

@@ -0,0 +1,136 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import { extractDomain } from "../domain/extract-domain.js";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseImgproxyUrl } from "../parse/parse-url.js";
import type { SignerHashLike } from "../signing/sign-path.js";
import {
recordRewriteDurationMs,
recordRewriteOutcome,
} from "../telemetry/telemetry.js";
import type { ImgproxyConfig, RewriteResult } from "../types/types.js";
import { buildImgproxyUrl } from "../url-builder/url-builder.js";
/** Inputs for {@link ImgproxyHandler.rewrite}. */
export interface RewriteRequest {
/** Incoming URL path (e.g. `/uv16:9/avatar.png` or `/unsafe/...`). */
readonly path: string;
/** Incoming Host header (used to derive the S3 sub-path). */
readonly host: string;
}
/** Constructor options for {@link ImgproxyHandler}. */
export interface ImgproxyHandlerOptions {
readonly config: ImgproxyConfig;
/** Wall-clock for histogram timing; defaults to `Date.now`. */
readonly clock?: Clock | undefined;
/** Structured logger for parse warnings; defaults to a silent no-op. */
readonly logger?: LoggerLike | undefined;
/** Optional injectable signer hash. */
readonly hash?: SignerHashLike | undefined;
}
/** Exported for testability — every method is a no-op. */
export const NOOP_LOGGER: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const defaultClock: Clock = () => Date.now();
/**
* High-level orchestrator that wraps `parseImgproxyUrl` +
* `extractDomain` + `buildImgproxyUrl` into a single
* `rewrite(request)` call, emitting telemetry and structured logs
* along the way.
*
* Returns a discriminated `RewriteResult`:
* - `{ rewritten: true; targetPath; operation }` on success.
* - `{ rewritten: false; reason: "passthrough" }` when the input
* path is a direct imgproxy path (`/unsafe/...` or `/health*`)
* that should bypass rewriting.
* - `{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`
* when the path does not match the imgproxy grammar.
*/
export class ImgproxyHandler {
private readonly config: ImgproxyConfig;
private readonly clock: Clock;
private readonly logger: LoggerLike;
private readonly hash?: SignerHashLike | undefined;
public constructor(options: ImgproxyHandlerOptions) {
this.config = options.config;
this.clock = options.clock ?? defaultClock;
this.logger = options.logger ?? NOOP_LOGGER;
this.hash = options.hash;
}
public rewrite(request: RewriteRequest): RewriteResult {
const start = this.clock();
const result = this.executeRewrite(request);
const duration = this.clock() - start;
recordRewriteDurationMs(duration);
return result;
}
private executeRewrite(request: RewriteRequest): RewriteResult {
if (request.host.length === 0) {
const err = new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_HOST,
"host header is required",
{ context: { path: request.path } }
);
this.logger.warn("imgproxy host missing", {
code: err.code,
});
recordRewriteOutcome("invalid", "");
return {
rewritten: false,
reason: "invalid",
errorCode: err.code,
errorMessage: err.message,
};
}
try {
const parsed = parseImgproxyUrl(request.path);
const domain = extractDomain(request.host);
const targetPath = buildImgproxyUrl({
parsed,
domain,
config: this.config,
hash: this.hash,
});
recordRewriteOutcome("rewritten", parsed.operation);
return {
rewritten: true,
targetPath,
operation: parsed.operation,
};
} catch (err) {
const caddyErr = err as CaddyImgproxyError;
if (
request.path.startsWith("/unsafe/") ||
request.path.startsWith("/health")
) {
recordRewriteOutcome("passthrough", "");
return { rewritten: false, reason: "passthrough" };
}
this.logger.warn("imgproxy parse failed", {
path: request.path,
code: caddyErr.code,
});
recordRewriteOutcome("invalid", "");
return {
rewritten: false,
reason: "invalid",
errorCode: caddyErr.code,
errorMessage: caddyErr.message,
};
}
}
}

View File

@@ -0,0 +1,2 @@
export type { ImgproxyHandlerOptions, RewriteRequest } from "./handler.js";
export { ImgproxyHandler } from "./handler.js";

View File

@@ -0,0 +1,50 @@
export { ImgproxyConfigSchema, parseConfig } from "./config/config.js";
export { calculateDimensions } from "./dimensions/calculate-dimensions.js";
export { extractDomain } from "./domain/extract-domain.js";
export type { CaddyImgproxyCode } from "./error-codes/error-codes.js";
export {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "./error-codes/error-codes.js";
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity/gravity.js";
export type {
ImgproxyHandlerOptions,
RewriteRequest,
} from "./handler/handler.js";
export { ImgproxyHandler } from "./handler/handler.js";
export { operationToImgproxy } from "./operation/operation.js";
export { parseImgproxyUrl } from "./parse/parse-url.js";
export type {
SignerHashLike,
SignImgproxyPathOptions,
} from "./signing/sign-path.js";
export { signImgproxyPath } from "./signing/sign-path.js";
export type {
ParseOutcome,
RewriteOutcome,
SignMode,
} from "./telemetry/telemetry.js";
export {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry/telemetry.js";
export type {
GravityCode,
HintType,
ImgproxyConfig,
ImgproxyDimensions,
ImgproxyOperation,
ParsedImgproxyUrl,
RewriteInvalidResult,
RewritePassthroughResult,
RewriteResult,
RewriteRewrittenResult,
} from "./types/types.js";
export type { BuildImgproxyUrlOptions } from "./url-builder/url-builder.js";
export { buildImgproxyUrl } from "./url-builder/url-builder.js";

View File

@@ -0,0 +1 @@
export { operationToImgproxy } from "./operation.js";

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,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { operationToImgproxy } from "./operation.js";
describe("operationToImgproxy", () => {
it("cover with both dims emits gravity-prefixed fill", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("cover", 1920, 1080, "t")).toBe(
"g:no/rs:fill:1920:1080"
);
});
it("cover with width only emits g:<g>/w:W", { timeout: 5000 }, () => {
expect(operationToImgproxy("cover", 800, 0, "c")).toBe("g:ce/w:800");
});
it("cover with height only emits g:<g>/h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("cover", 0, 600, "c")).toBe("g:ce/h:600");
});
it("cover with both dims zero emits empty string", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("cover", 0, 0, "c")).toBe("");
});
it("fit emits rs:fit:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 800, 600, "c")).toBe(
"rs:fit:800:600"
);
});
it("fit with width only emits w:W", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 800, 0, "c")).toBe("w:800");
});
it("fit with height only emits h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 0, 600, "c")).toBe("h:600");
});
it("fit with no dims emits empty string", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 0, 0, "c")).toBe("");
});
it("crop emits g:<g>/c:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("crop", 800, 600, "tl")).toBe(
"g:nowe/c:800:600"
);
});
it("crop without both dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("crop", 800, 0, "c")).toBe("");
});
it("stretch emits rs:force:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("stretch", 800, 600, "c")).toBe(
"rs:force:800:600"
);
});
it("stretch without both dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("stretch", 800, 0, "c")).toBe("");
});
it("resize emits w:W or h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("resize", 800, 0, "c")).toBe("w:800");
expect(operationToImgproxy("resize", 0, 600, "c")).toBe("h:600");
});
it("resize without dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("resize", 0, 0, "c")).toBe("");
});
it("passthrough and unknown operations emit empty string", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("", 800, 600, "c")).toBe("");
});
});

View File

@@ -0,0 +1,60 @@
import { gravityToImgproxy } from "../gravity/gravity.js";
import type { ImgproxyOperation } from "../types/types.js";
/**
* Converts a resolved operation + dimensions + gravity into the
* imgproxy processing-string segment.
*
* Mirrors the upstream Go `operationToImgproxy` switch:
* - `cover` → `g:<g>/rs:fill:W:H` when both dims present;
* otherwise `g:<g>/w:W` or `g:<g>/h:H`. Gravity MUST come
* before the resize directive for it to take effect during
* cropping.
* - `fit` → `rs:fit:W:H` (gravity does not apply); or `w:W` /
* `h:H` for one-axis fits.
* - `crop` → `g:<g>/c:W:H`. Gravity prefix required to anchor.
* - `stretch` → `rs:force:W:H`.
* - `resize` → `w:W` or `h:H`.
* - `""` (passthrough) → empty string.
*/
export function operationToImgproxy(
op: ImgproxyOperation,
width: number,
height: number,
gravity: string
): string {
const g = gravityToImgproxy(gravity);
switch (op) {
case "cover":
if (width > 0 && height > 0) {
return `g:${g}/rs:fill:${width}:${height}`;
}
if (width > 0) return `g:${g}/w:${width}`;
if (height > 0) return `g:${g}/h:${height}`;
return "";
case "fit":
if (width > 0 && height > 0) {
return `rs:fit:${width}:${height}`;
}
if (width > 0) return `w:${width}`;
if (height > 0) return `h:${height}`;
return "";
case "crop":
if (width > 0 && height > 0) {
return `g:${g}/c:${width}:${height}`;
}
return "";
case "stretch":
if (width > 0 && height > 0) {
return `rs:force:${width}:${height}`;
}
return "";
case "resize":
if (width > 0) return `w:${width}`;
if (height > 0) return `h:${height}`;
return "";
case "":
return "";
}
}

View File

@@ -0,0 +1 @@
export { parseImgproxyUrl } from "./parse-url.js";

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,233 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseImgproxyUrl } from "./parse-url.js";
describe("parseImgproxyUrl", () => {
it("parses passthrough unsigned URL", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/u/photo.png");
expect(result.signed).toBe(false);
expect(result.operation).toBe("");
expect(result.gravity).toBe("c");
expect(result.path).toBe("photo");
expect(result.format).toBe("png");
expect(result.hasDims).toBe(false);
});
it("parses passthrough signed URL", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/s/photo.png");
expect(result.signed).toBe(true);
expect(result.operation).toBe("");
expect(result.path).toBe("photo");
expect(result.format).toBe("png");
});
it("parses cover with aspect ratio, top gravity, height hint", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uvt9:16h1080/photos/hero.webp");
expect(result.operation).toBe("cover");
expect(result.gravity).toBe("t");
expect(result.aspectW).toBe(9);
expect(result.aspectH).toBe(16);
expect(result.hintType).toBe("h");
expect(result.hintValue).toBe(1080);
expect(result.path).toBe("photos/hero");
expect(result.format).toBe("webp");
expect(result.isAspect).toBe(true);
});
it("parses cover with aspect ratio, center gravity default", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uv16:9/avatar.png");
expect(result.operation).toBe("cover");
expect(result.gravity).toBe("c");
expect(result.aspectW).toBe(16);
expect(result.aspectH).toBe(9);
expect(result.isAspect).toBe(true);
});
it("parses fit with pixel dimensions both axes", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf100x100/avatar.jpg");
expect(result.operation).toBe("fit");
expect(result.pixelW).toBe(100);
expect(result.pixelH).toBe(100);
expect(result.isAspect).toBe(false);
});
it("parses fit with width only (auto height)", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x0/banner.webp");
expect(result.pixelW).toBe(800);
expect(result.pixelH).toBe(0);
});
it("parses fit with height only (auto width)", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf0x600/portrait.jpg");
expect(result.pixelW).toBe(0);
expect(result.pixelH).toBe(600);
});
it("parses crop with aspect ratio and bottom-left gravity", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
expect(result.operation).toBe("crop");
expect(result.gravity).toBe("bl");
expect(result.aspectW).toBe(4);
expect(result.aspectH).toBe(3);
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses stretch with pixel dimensions", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/us800x600/photo.jpg");
expect(result.operation).toBe("stretch");
expect(result.pixelW).toBe(800);
expect(result.pixelH).toBe(600);
});
it("parses resize with width only", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/ur800x0/photo.jpg");
expect(result.operation).toBe("resize");
expect(result.pixelW).toBe(800);
});
it("parses cover with top-right gravity", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uvtr16:9/photo.png");
expect(result.gravity).toBe("tr");
});
it("parses cover with bottom-right gravity", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uvbr16:9/photo.png");
expect(result.gravity).toBe("br");
});
it("parses cover with width hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9w1920/photo.png");
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses fit with quality", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x600q85/photo.jpg");
expect(result.quality).toBe(85);
});
it("parses cover with quality 100", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9q100/photo.jpg");
expect(result.quality).toBe(100);
});
it("parses square shorthand - cover 128", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv128/avatar.png");
expect(result.pixelW).toBe(128);
expect(result.pixelH).toBe(128);
expect(result.isAspect).toBe(false);
expect(result.hasDims).toBe(true);
});
it("parses square shorthand - fit 64 with quality", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uf64q90/avatar.jpg");
expect(result.pixelW).toBe(64);
expect(result.pixelH).toBe(64);
expect(result.quality).toBe(90);
});
it("parses square shorthand - cover with gravity", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uvtr128/avatar.png");
expect(result.gravity).toBe("tr");
expect(result.pixelW).toBe(128);
});
it("parses deep path", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9/folder/subfolder/photo.png");
expect(result.path).toBe("folder/subfolder/photo");
});
it("parses quality before width hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x0q85w1920/avatar.jpg");
expect(result.quality).toBe(85);
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses quality before height hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv9:16q85h1080/photo.jpg");
expect(result.quality).toBe(85);
expect(result.hintType).toBe("h");
expect(result.hintValue).toBe(1080);
});
it("parses pixel dims with quality first", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf100x100q75/photo.jpg");
expect(result.quality).toBe(75);
expect(result.pixelW).toBe(100);
});
it("raises MISSING_DIMENSIONS when operation is specified without dimensions", {
timeout: 5000,
}, () => {
try {
parseImgproxyUrl("/uv/photo.png");
expect.fail("expected MISSING_DIMENSIONS");
} catch (err) {
expect(err).toBeInstanceOf(CaddyImgproxyError);
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
);
}
});
it("raises INVALID_URL on missing prefix", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/photo.png");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on quality zero", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/uf800x600q0/photo.jpg");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on quality over 100", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/uf800x600q101/photo.jpg");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on aspect ratio with zero", {
timeout: 5000,
}, () => {
try {
parseImgproxyUrl("/uv0:9/photo.png");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
});

View File

@@ -0,0 +1,195 @@
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { recordParseOutcome } from "../telemetry/telemetry.js";
import type {
GravityCode,
HintType,
ImgproxyOperation,
ParsedImgproxyUrl,
} from "../types/types.js";
/**
* Matches the compact imgproxy URL grammar. Capture groups:
* 1: signed/unsigned (`s`|`u`)
* 2: operation (`v`|`c`|`f`|`s`|`r`) — optional
* 3: gravity (`tl`|`tr`|`bl`|`br`|`t`|`b`|`l`|`r`|`c`) — optional
* 4: aspect W (>=1)
* 5: aspect H (>=1)
* 6: pixel W (>=1, when followed by xH)
* 7: pixel H (>=0)
* 8: pixel W (=0)
* 9: pixel H (>=1, when W=0)
* 10: square dim
* 11: modifiers
* 12: path
* 13: format
*/
const URL_PATTERN =
/^\/([su])([vcfsr])?(tl|tr|bl|br|[tblrc])?(?:([1-9]\d*):([1-9]\d*)|([1-9]\d*)x(\d+)|(0)x([1-9]\d*)|([1-9]\d*))?((?:[wh][1-9]\d*|q(?:100|[1-9]\d?))*)\/(.+)\.([a-z]+)$/;
/** Extracts individual `w<int>`, `h<int>`, `q<int>` modifiers. */
const MODIFIER_PATTERN = /([whq])(\d+)/g;
const OP_MAP: Record<string, ImgproxyOperation> = {
v: "cover",
c: "crop",
f: "fit",
s: "stretch",
r: "resize",
};
/**
* Parses an imgproxy URL into its typed components.
*
* Verbatim port of the upstream Go parseURL. Returns a frozen
* ParsedImgproxyUrl on success and throws CaddyImgproxyError on
* every failure. Increments caddy_imgproxy_parse_total once per call.
*/
export function parseImgproxyUrl(path: string): ParsedImgproxyUrl {
const matches = URL_PATTERN.exec(path);
if (matches === null) {
recordParseOutcome("invalid");
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_URL,
"imgproxy URL does not match the expected grammar",
{ context: { path } }
);
}
const opToken = matches[2] ?? "";
const operation =
opToken === "" ? "" : (OP_MAP[opToken] as ImgproxyOperation);
const gravity = resolveGravity(matches[3]);
const { aspectW, aspectH, pixelW, pixelH, isAspect, hasDims } =
resolveDimensions(matches);
const { hintType, hintValue, quality } = parseModifiers(
matches[11] as string
);
if (operation !== "" && !hasDims) {
recordParseOutcome("missing_dims");
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS,
`dimensions required for operation: ${operation}`,
{ context: { path, operation } }
);
}
recordParseOutcome("ok");
return Object.freeze({
signed: matches[1] === "s",
operation,
gravity,
aspectW,
aspectH,
pixelW,
pixelH,
hintType,
hintValue,
quality,
path: matches[12] as string,
format: matches[13] as string,
isAspect,
hasDims,
});
}
function resolveGravity(token: string | undefined): GravityCode {
if (token === undefined || token === "") return "c";
return token as GravityCode;
}
interface DimensionParts {
readonly aspectW: number;
readonly aspectH: number;
readonly pixelW: number;
readonly pixelH: number;
readonly isAspect: boolean;
readonly hasDims: boolean;
}
function resolveDimensions(matches: RegExpExecArray): DimensionParts {
if (matches[4] !== undefined && matches[5] !== undefined) {
return {
aspectW: Number.parseInt(matches[4], 10),
aspectH: Number.parseInt(matches[5], 10),
pixelW: 0,
pixelH: 0,
isAspect: true,
hasDims: true,
};
}
if (matches[6] !== undefined) {
return {
aspectW: 0,
aspectH: 0,
pixelW: Number.parseInt(matches[6], 10),
pixelH: Number.parseInt(matches[7] as string, 10),
isAspect: false,
hasDims: true,
};
}
if (matches[8] !== undefined) {
return {
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: Number.parseInt(matches[9] as string, 10),
isAspect: false,
hasDims: true,
};
}
if (matches[10] !== undefined) {
const dim = Number.parseInt(matches[10], 10);
return {
aspectW: 0,
aspectH: 0,
pixelW: dim,
pixelH: dim,
isAspect: false,
hasDims: true,
};
}
return {
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: 0,
isAspect: false,
hasDims: false,
};
}
interface ModifierParts {
readonly hintType: HintType;
readonly hintValue: number;
readonly quality: number;
}
function parseModifiers(token: string): ModifierParts {
let hintType: HintType = "";
let hintValue = 0;
let quality = 0;
if (token === "") {
return { hintType, hintValue, quality };
}
MODIFIER_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = MODIFIER_PATTERN.exec(token);
while (match !== null) {
const kind = match[1];
const value = Number.parseInt(match[2] as string, 10);
if (kind === "w") {
hintType = "w";
hintValue = value;
} else if (kind === "h") {
hintType = "h";
hintValue = value;
} else {
quality = value;
}
match = MODIFIER_PATTERN.exec(token);
}
return { hintType, hintValue, quality };
}

View File

@@ -0,0 +1,2 @@
export type { SignerHashLike, SignImgproxyPathOptions } from "./sign-path.js";
export { signImgproxyPath } from "./sign-path.js";

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,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
type CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { type SignerHashLike, signImgproxyPath } from "./sign-path.js";
const KEY = "aa11bbcc";
const SALT = "deadbeef";
const PATH = "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png";
const EXPECTED = "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U";
describe("signImgproxyPath", () => {
it("matches the known-answer signature vector", { timeout: 5000 }, () => {
const result = signImgproxyPath({
key: KEY,
salt: SALT,
path: PATH,
});
expect(result).toBe(EXPECTED);
});
it("raises INVALID_KEY for malformed hex key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "xyz", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY for odd-length key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "abc", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY for empty key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_SALT for malformed hex salt", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: KEY, salt: "zzz", path: PATH });
expect.fail("expected INVALID_SALT");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_SALT
);
}
});
it("uses the injected hash implementation", { timeout: 5000 }, () => {
let invoked = false;
const hash: SignerHashLike = {
hmac: (algorithm, _key, _data) => {
invoked = true;
expect(algorithm).toBe("SHA-256");
return new Uint8Array([1, 2, 3, 4]);
},
};
const result = signImgproxyPath({
key: KEY,
salt: SALT,
path: PATH,
hash,
});
expect(invoked).toBe(true);
// toBase64Url of [1,2,3,4] = "AQIDBA"
expect(result).toBe("AQIDBA");
});
});

View File

@@ -0,0 +1,76 @@
import { fromHex, hmac, toBase64Url } from "@suaveplan/crypto-hash";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
/**
* Pluggable HMAC implementation. Allows a host to swap in a native
* implementation, an HSM-backed signer, or a deterministic stub for
* tests. The default uses `@suaveplan/crypto-hash`'s `hmac`.
*/
export interface SignerHashLike {
hmac(algorithm: "SHA-256", key: Uint8Array, data: Uint8Array): Uint8Array;
}
const defaultHash: SignerHashLike = {
hmac: (algorithm, key, data) => hmac(algorithm, key, data),
};
/** Inputs for {@link signImgproxyPath}. */
export interface SignImgproxyPathOptions {
/** Lower-hex HMAC key (even length). */
readonly key: string;
/** Lower-hex salt (even length). */
readonly salt: string;
/** Imgproxy path being signed (including leading slash + segments). */
readonly path: string;
/** Optional injectable hash implementation. */
readonly hash?: SignerHashLike | undefined;
}
const HEX_RE = /^[0-9a-f]*$/u;
const utf8 = new TextEncoder();
/**
* Decodes a hex string into bytes, raising the supplied
* caddy-imgproxy code on failure. The hex validation is stricter than
* `crypto-hash.fromHex` (lower-case only) to keep wire-format
* round-trips deterministic.
*/
function decodeHex(
hex: string,
code: "INVALID_KEY" | "INVALID_SALT",
field: string
): Uint8Array {
if (hex.length === 0 || hex.length % 2 !== 0 || !HEX_RE.test(hex)) {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES[code],
`${field} must be a non-empty even-length lower-hex string`,
{ context: { field } }
);
}
return fromHex(hex);
}
/**
* Computes the imgproxy URL signature.
*
* The signature is `HMAC-SHA256(keyBytes, saltBytes || pathBytes)`,
* base64url encoded without padding. Matches the upstream Go
* `signPath` byte-for-byte so any URL signed by the Go module
* verifies against the TypeScript port and vice-versa.
*/
export function signImgproxyPath(options: SignImgproxyPathOptions): string {
const keyBytes = decodeHex(options.key, "INVALID_KEY", "key");
const saltBytes = decodeHex(options.salt, "INVALID_SALT", "salt");
const pathBytes = utf8.encode(options.path);
const data = new Uint8Array(saltBytes.length + pathBytes.length);
data.set(saltBytes, 0);
data.set(pathBytes, saltBytes.length);
const hash = options.hash ?? defaultHash;
const digest = hash.hmac("SHA-256", keyBytes, data);
return toBase64Url(digest);
}

View File

@@ -0,0 +1,11 @@
export type { ParseOutcome, RewriteOutcome, SignMode } from "./telemetry.js";
export {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry.js";

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,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry.js";
describe("caddy-imgproxy telemetry", () => {
it("exposes counters + histogram", { timeout: 5000 }, () => {
expect(parseTotalCounter).toBeDefined();
expect(rewriteTotalCounter).toBeDefined();
expect(signTotalCounter).toBeDefined();
expect(rewriteDurationHistogram).toBeDefined();
});
it("records parse outcomes", { timeout: 5000 }, () => {
expect(() => recordParseOutcome("ok")).not.toThrow();
expect(() => recordParseOutcome("invalid")).not.toThrow();
expect(() => recordParseOutcome("missing_dims")).not.toThrow();
});
it("records rewrite outcomes and durations", { timeout: 5000 }, () => {
expect(() => recordRewriteOutcome("rewritten", "cover")).not.toThrow();
expect(() => recordRewriteOutcome("passthrough", "")).not.toThrow();
expect(() => recordRewriteOutcome("invalid", "")).not.toThrow();
expect(() => recordRewriteDurationMs(42)).not.toThrow();
});
it("records sign modes", { timeout: 5000 }, () => {
expect(() => recordSignMode("signed")).not.toThrow();
expect(() => recordSignMode("unsafe")).not.toThrow();
});
});

View File

@@ -0,0 +1,80 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/caddy-imgproxy";
/**
* Lazy-bound meter — resolves the globally-registered `MeterProvider`
* on first access. When no provider is registered the OTel API returns
* a no-op meter, so the instruments are safe to import in production
* code that does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for parse-call outcomes. */
export const parseTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_parse_total",
{
description:
"Total parseImgproxyUrl calls, tagged with outcome (ok|invalid|missing_dims)",
}
);
/** Counter for rewrite-call outcomes. */
export const rewriteTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_rewrite_total",
{
description:
"Total ImgproxyHandler.rewrite calls, tagged with outcome (rewritten|passthrough|invalid) and operation",
}
);
/** Counter for sign / unsafe modes. */
export const signTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_sign_total",
{
description:
"Total buildImgproxyUrl calls, tagged with mode (signed|unsafe)",
}
);
/** Histogram for ImgproxyHandler.rewrite end-to-end duration in milliseconds. */
export const rewriteDurationHistogram: Histogram = meter.createHistogram(
"caddy_imgproxy_rewrite_duration_ms",
{
description:
"ImgproxyHandler.rewrite end-to-end duration in milliseconds",
unit: "ms",
}
);
/** Parse outcome tag — one tick per `parseImgproxyUrl` call. */
export type ParseOutcome = "ok" | "invalid" | "missing_dims";
/** Rewrite outcome tag. */
export type RewriteOutcome = "rewritten" | "passthrough" | "invalid";
/** Sign mode tag. */
export type SignMode = "signed" | "unsafe";
/** Records a `caddy_imgproxy_parse_total` data point. */
export function recordParseOutcome(outcome: ParseOutcome): void {
parseTotalCounter.add(1, { outcome });
}
/** Records a `caddy_imgproxy_rewrite_total` data point with operation tag. */
export function recordRewriteOutcome(
outcome: RewriteOutcome,
operation: string
): void {
rewriteTotalCounter.add(1, { outcome, operation });
}
/** Records a `caddy_imgproxy_sign_total` data point. */
export function recordSignMode(mode: SignMode): void {
signTotalCounter.add(1, { mode });
}
/** Records a duration sample on the rewrite histogram. */
export function recordRewriteDurationMs(durationMs: number): void {
rewriteDurationHistogram.record(durationMs);
}

View File

@@ -0,0 +1,12 @@
export type {
GravityCode,
HintType,
ImgproxyConfig,
ImgproxyDimensions,
ImgproxyOperation,
ParsedImgproxyUrl,
RewriteInvalidResult,
RewritePassthroughResult,
RewriteResult,
RewriteRewrittenResult,
} from "./types.js";

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,113 @@
/**
* Shared, runtime-free type definitions for the @suaveplan/caddy-imgproxy
* package. These are the cross-feature contracts that flow between
* `parse`, `dimensions`, `url-builder`, `handler`, and `config`.
*/
/** Imgproxy operation codes — the empty string is "passthrough" (no resize). */
export type ImgproxyOperation =
| ""
| "cover"
| "crop"
| "fit"
| "stretch"
| "resize";
/** Gravity / anchor grammar — 9 directions plus center default. */
export type GravityCode =
| "t"
| "b"
| "l"
| "r"
| "c"
| "tl"
| "tr"
| "bl"
| "br";
/** Modifier hint type — either width-driven (`w`), height-driven (`h`), or none (`""`). */
export type HintType = "" | "w" | "h";
/**
* Parsed representation of an imgproxy URL — the output of
* `parseImgproxyUrl`. Mirrors the Go `ParsedURL` struct field-for-field
* so the TypeScript and Go implementations can be tested against the
* same fixture corpus.
*/
export interface ParsedImgproxyUrl {
/** `true` when the URL had the `s` prefix (signed), `false` for `u`. */
readonly signed: boolean;
/** Resolved operation; empty string means "passthrough". */
readonly operation: ImgproxyOperation;
/** Resolved gravity anchor; defaults to `"c"` (center) when omitted. */
readonly gravity: GravityCode;
/** Aspect ratio numerator (e.g. `16` in `16:9`); `0` when not aspect mode. */
readonly aspectW: number;
/** Aspect ratio denominator (e.g. `9` in `16:9`); `0` when not aspect mode. */
readonly aspectH: number;
/** Explicit pixel width; `0` for auto. */
readonly pixelW: number;
/** Explicit pixel height; `0` for auto. */
readonly pixelH: number;
/** Hint axis — width (`w`), height (`h`), or none (`""`). */
readonly hintType: HintType;
/** Hint magnitude in pixels. */
readonly hintValue: number;
/** JPEG-style quality 1..100; `0` means "default / unspecified". */
readonly quality: number;
/** Image path after the prefix and before the extension. */
readonly path: string;
/** Output format extension (e.g. `png`, `webp`). */
readonly format: string;
/** `true` when dimensions came from an aspect ratio expression. */
readonly isAspect: boolean;
/** `true` when any dimension was supplied. */
readonly hasDims: boolean;
}
/**
* Public configuration for the imgproxy URL rewriter. Validated by
* `ImgproxyConfigSchema` / `parseConfig`. `key` and `salt` are
* lower-hex strings; their absence flips the rewriter into `/unsafe`
* mode (matching the Go module's behaviour).
*/
export interface ImgproxyConfig {
readonly upstream: string;
readonly s3Bucket: string;
readonly defaultWidth: number;
readonly key?: string | undefined;
readonly salt?: string | undefined;
}
/** Resolved pixel dimensions — the output of `calculateDimensions`. */
export interface ImgproxyDimensions {
readonly width: number;
readonly height: number;
}
/** Result of `ImgproxyHandler.rewrite`. Discriminated by `rewritten`. */
export type RewriteResult =
| RewriteRewrittenResult
| RewritePassthroughResult
| RewriteInvalidResult;
/** A URL that successfully rewrote into an imgproxy target. */
export interface RewriteRewrittenResult {
readonly rewritten: true;
readonly targetPath: string;
readonly operation: ImgproxyOperation;
}
/** A URL that should bypass rewriting (e.g. `/unsafe/...` or `/health`). */
export interface RewritePassthroughResult {
readonly rewritten: false;
readonly reason: "passthrough";
}
/** A URL that failed to parse — surfaced for logging / error handling. */
export interface RewriteInvalidResult {
readonly rewritten: false;
readonly reason: "invalid";
readonly errorCode: string;
readonly errorMessage: string;
}

View File

@@ -0,0 +1,2 @@
export type { BuildImgproxyUrlOptions } from "./url-builder.js";
export { buildImgproxyUrl } from "./url-builder.js";

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).

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseConfig } from "../config/config.js";
import { parseImgproxyUrl } from "../parse/parse-url.js";
import { buildImgproxyUrl } from "./url-builder.js";
describe("buildImgproxyUrl", () => {
it("composes /unsafe URL when no key/salt", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({ defaultWidth: 800 });
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toBe(
"/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
);
});
it("composes signed URL for cover with aspect ratio", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toBe(
"/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
);
});
it("inserts q:<quality> when parsed.quality > 0", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/uf800x600q85/photo.jpg");
const config = parseConfig({});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toContain("/q:85/");
});
it("emits passthrough URL when operation is empty", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const config = parseConfig({});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
// No processing segment since operation === ""
expect(result).toBe("/unsafe/plain/s3://images/example.com/photo@png");
});
it("records caddy_imgproxy_sign_total for signed and unsafe modes", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const unsafeCfg = parseConfig({});
const signedCfg = parseConfig({
key: "aa11bbcc",
salt: "deadbeef",
});
const a = buildImgproxyUrl({
parsed,
domain: "example.com",
config: unsafeCfg,
});
const b = buildImgproxyUrl({
parsed,
domain: "example.com",
config: signedCfg,
});
expect(a.startsWith("/unsafe/")).toBe(true);
expect(
b.startsWith("/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/")
).toBe(true);
});
it("uses /unsafe when key is set but salt missing", {
timeout: 5000,
}, () => {
// Manually construct a config with key but no salt to hit the
// branch where only one half is set.
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "aa11",
salt: undefined,
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
it("treats empty-string key as unsafe mode", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "",
salt: "deadbeef",
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
it("treats empty-string salt as unsafe mode", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "aa11",
salt: "",
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { calculateDimensions } from "../dimensions/calculate-dimensions.js";
import { operationToImgproxy } from "../operation/operation.js";
import { type SignerHashLike, signImgproxyPath } from "../signing/sign-path.js";
import { recordSignMode } from "../telemetry/telemetry.js";
import type { ImgproxyConfig, ParsedImgproxyUrl } from "../types/types.js";
/** Inputs for {@link buildImgproxyUrl}. */
export interface BuildImgproxyUrlOptions {
/** A `ParsedImgproxyUrl` previously produced by `parseImgproxyUrl`. */
readonly parsed: ParsedImgproxyUrl;
/** Tenant / virtual-host segment to interpolate into the S3 path. */
readonly domain: string;
/** Validated imgproxy configuration (defaults + optional key/salt). */
readonly config: ImgproxyConfig;
/** Optional injectable hash implementation for the signer. */
readonly hash?: SignerHashLike | undefined;
}
/**
* Composes the final imgproxy URL path from a parsed grammar
* representation. Re-implements the Go `buildImgproxyURL` semantics:
*
* 1. Resolve `(width, height)` from the parsed dims + default width.
* 2. Resolve the processing string for the operation + dims + gravity.
* 3. Append `q:<quality>` when `parsed.quality > 0`.
* 4. Append `plain/s3://<bucket>/<domain>/<path>@<format>`.
* 5. Prefix with `<signature>` (signed mode) or `/unsafe`.
*
* Records `caddy_imgproxy_sign_total` exactly once per call.
*/
export function buildImgproxyUrl(options: BuildImgproxyUrlOptions): string {
const { parsed, domain, config } = options;
const { width, height } = calculateDimensions(parsed, config.defaultWidth);
const processing = operationToImgproxy(
parsed.operation,
width,
height,
parsed.gravity
);
const s3Path = `s3://${config.s3Bucket}/${domain}/${parsed.path}`;
const segments: string[] = [];
if (processing !== "") segments.push(processing);
if (parsed.quality > 0) segments.push(`q:${parsed.quality}`);
segments.push("plain", `${s3Path}@${parsed.format}`);
const innerPath = `/${segments.join("/")}`;
if (
config.key !== undefined &&
config.salt !== undefined &&
config.key.length > 0 &&
config.salt.length > 0
) {
const signature = signImgproxyPath({
key: config.key,
salt: config.salt,
path: innerPath,
hash: options.hash,
});
recordSignMode("signed");
return `/${signature}${innerPath}`;
}
recordSignMode("unsafe");
return `/unsafe${innerPath}`;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/media-image-caddy-imgproxy",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"parse/index": "src/parse/index.ts",
"dimensions/index": "src/dimensions/index.ts",
"gravity/index": "src/gravity/index.ts",
"operation/index": "src/operation/index.ts",
"signing/index": "src/signing/index.ts",
"url-builder/index": "src/url-builder/index.ts",
"domain/index": "src/domain/index.ts",
"handler/index": "src/handler/index.ts",
"config/index": "src/config/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"types/index": "src/types/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/crypto-hash",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});