Hooks¶
The contract iiiris maintains for the request-time customization seam —
the internal/hook.Hook interface and its bundled engines. Operator
how-to lives in docs/hooks.md; this spec is the durable
"what the subsystem guarantees" reference a re-implementation must satisfy.
What it does¶
A Hook lets user-supplied logic adjust three points of an image/info
request — where an identifier resolves, whether it's authorized, and
whether it redirects — plus two optional capabilities: Presentation
descriptor overrides and image overlays (watermarking). The default is a
no-op, so the rest of the system calls through a hook unconditionally.
Hooks are selected and configured per deployment; zero-config startup
uses Noop.
Surface¶
Public Go API¶
type Hook interface {
Resolve(ctx context.Context, identifier string) (ResolveResult, error)
Authorize(ctx context.Context, req AuthRequest) (AuthDecision, error)
Redirect(ctx context.Context, identifier string) (string, error)
}
type ResolveResult struct{ Backend, Key string }
type AuthRequest struct {
Identifier string
Path string
Header http.Header // read-only by convention
}
type AuthDecision struct {
Allow bool
Reason, Profile, Substitute, Redirect string
}
Optional capabilities, detected by type assertion:
// presentation.Describer
Describe(ctx context.Context, identifier string) (Descriptor, bool, error)
// hook.Overlayer — composite an image overlay (watermark) onto image responses
Overlay(ctx context.Context, req OverlayRequest) (*OverlaySpec, error)
type OverlayRequest struct {
Identifier string
Path string
Header http.Header // read-only by convention
OutputWidth, OutputHeight int // final derivative dims (post region+size)
}
type OverlaySpec struct {
Backend, Key, Version string // overlay image, via source.Source; Key required
Target string // "output" (default) | "source"
Anchor string // nine-position grid; "" = center
Fit string // "none"(default) | "shrink" | "scale" | "tile"
Scale float64 // Fit=scale: overlay longest side / base longest side
OffsetX, OffsetY int // inset from the anchor, in the target's pixel space
Opacity int // 0–100; 0 ⇒ default 100
}
A nil *OverlaySpec (or a hook that doesn't implement Overlayer) means "no
overlay". OverlaySpec.CacheFragment() returns the canonical string the server
folds into the render-cache key.
Constructors, all returning a Hook, wired by hook.Build(config.HookConfig):
NewLookup(backend, template string)NewJS(scriptPath, scriptInline string, watchInterval, timeout time.Duration)NewLua(scriptPath, scriptInline string, watchInterval time.Duration)NewWebhook(url string, timeout time.Duration, authHeader string)
Engines¶
hook.type |
Engine | Notes |
|---|---|---|
"" / noop |
Noop |
Default. Pass-through resolve, allow-all auth, no redirect. |
lookup |
Lookup |
Go text/template identifier→key, optional backend pin. |
js |
JS |
Sandboxed JavaScript (goja, pure-Go). Recommended scripting engine. |
lua |
Lua |
Sandboxed Lua 5.1 (gopher-lua). Deprecated — removed in iiiris 1.0. |
webhook |
Webhook |
Out-of-process HTTP POST per call. |
Config¶
config.HookConfig: type selector + per-engine blocks lookup,
js, lua, webhook. The scripting blocks:
js.{path, script, watch_interval, timeout}— one ofpath/scriptrequired;watch_interval0=default(5s)/<0=disabled;timeout0=default(1s).lua.{path, script, watch_interval}— same, no per-call timeout.
No environment-variable overrides for hook config.
Script ABI (js)¶
A js script assigns a handler object to module.exports. All methods
optional; a missing or non-function property is pass-through.
| Method | Signature | Returns |
|---|---|---|
resolve |
(identifier) |
{backend, key} or null |
authorize |
(identifier, ctx) where ctx = {path, headers} |
{allow, reason, profile, substitute, redirect} or null |
redirect |
(identifier) |
URL string or null |
describe |
(identifier) |
{label, metadata:[{label,value}], canvases:{<base>:{label}}} or null |
overlay |
(identifier, ctx) where ctx = {path, headers, outputWidth, outputHeight} |
{backend, key, version, target, anchor, fit, scale, offsetX, offsetY, opacity} or null |
Host surface injected into the runtime: log(level, msg, fields?) only.
Contracts¶
- Zero-config default is
Noop. No hook configured ⇒ pass-through;Resolve/Redirectzero values mean "use defaults" (backend =sources.default, key = identifier),Authorizeallows. - Concurrency-safe. Every method may be called per request from many goroutines. Scripting engines pool per-generation VM/runtime states; scripts must be pure (no module-level mutation leaked across calls).
Resolvezero-value semantics. EmptyBackendfalls back to the default source; emptyKeyfalls back to the raw identifier.Authorizecan only restrict. It runs after the rule engine and may flip allow→deny or add metadata (Profile/Substitute/Redirect); it cannot lift a rule-driven deny. A nil/empty decision is allow.Redirectruns before auth. A non-empty URL produces an immediate 303, ahead of auth and IIIF parameter parsing. It wins over the auth-flow's ownDecision.Redirect.AuthRequest.Headeris read-only. Impls must not mutate it. Thejsengine exposes it as a name→first-value object;webhookdoes not forward it.- Failure semantics (scripting engines). A throw, timeout, or
malformed return is a server fault:
Resolve/Redirect→ 500 (no fallback to defaults);Authorize→ deny (fail closed);Describe→ fail open (override ignored, server-derived descriptor used, WARN logged). Timeouts are treated identically to errors. - Sandbox.
jsandluahave no filesystem, network, or process access.js: bare goja runtime, norequire/Node/timers, only thelog()host builtin.lua: onlybase/string/table/math, withloadstring/loadfile/dofile/loadstripped. - Hot reload (path mode). A changed script file (mtime, checked at
most once per
watch_interval) is recompiled and the generation swapped atomically; in-flight calls finish on the old generation. A recompile/init failure is logged WARN and the previous generation stays live. Inline scripts are never reloaded. Describeis opt-in. Onlyjsimplementspresentation.Describer; hooks that don't are skipped in descriptor layering.found=falsemeans "no opinion".Overlayis opt-in and fail-closed. Only hooks implementingOverlayeroverlay; a deployment without one does no overlay work and keeps the zero-touch cache hit.Overlayruns per image request (never on the info path), before the render-cache lookup so its decision keys the entry. A throw, timeout, malformed return, invalid enum, or unreadable overlay image is a server fault → 500 (fail closed — never serve an un-watermarked image for an overlay-keyed request). Anull/absent return is "no overlay".- Overlay validation + defaults.
Keyis required; emptyBackend⇒ default source. Defaults:target=output,anchor=center,fit=none,opacity=100(0 ⇒ 100).fit=tilerequirestarget=output;fit=scalerequiresscale>0. Invalidtarget/anchor/fitare malformed (fail closed); validation never coerces. - Overlay placement.
target=outputcomposites at pipeline stage 6 (after rotation + quality): fixed pixel size, upright, in the output colorspace.target=sourcecomposites before rotation, placement mapped through the request's region + size: it scales and rotates/mirrors with the image and is cropped away if the region excludes it. The image is loaded viasource.Source({backend, key}) like resolution; any decodable image is accepted and alpha is honoured. - Render-cache keying folds in the overlay. When (and only when) an
overlay applies, an append-only hash of the resolved
OverlaySpec(image reference +version, target, anchor, fit, scale, offsets, opacity) is appended to the render-cache key, composed alongside the auth tier. An un-overlaid request keeps the legacy path-keyed shape (no churn, nocacheKeyVersionbump); a distinct overlay never collides with a different overlay or an un-overlaid render. The hash keys the image reference, not its bytes — swap the file under the same key and bumpversionto invalidate. Resolving an overlay requires concrete output dimensions, so when anOverlayeris configured the server reads source dimensions before the cache lookup. - Cache keying is otherwise unaffected by hooks. Resolve/redirect
don't change the key — render/info caches key on the IIIF URL path,
not the resolved backend key (see
docs/specs/scaffold.md); the overlay segment above is the one exception. govips/prometheusisolation is preserved. The hook package depends on neither; thejsengine's only new dependency is the pure-Gogoja(no CGO), keeping the static/distroless build intact.
Out of scope¶
- Lua header access / per-call timeout —
luais deprecated; it keeps its pre-existing surface (identifier + path, no timeout) until removal in 1.0. No new investment. - Webhook header forwarding — the
AuthRequest.Headerfield is not forwarded in the webhook JSON envelope. Deferred to a follow-up. - Third-party package loading — no
require/importof external modules at runtime. Author dependency-free scripts (bundle/compile ahead of time; e.g. TypeScript → single.js). - WASM / other engines — the JS engine is the chosen mainstream scripting surface; alternative engines (wazero/WASM, CGO V8/CPython) were rejected for the pure-Go, no-build-step posture.
- Persistent cross-request state — scripts get per-call locals only; shared mutable state would break the purity + pooling invariant.
- Overlay scope — image overlays only (no text/string rendering);
one overlay per request (no list); plugin-only (no config-driven
"apply to all" block); blend mode is
overonly. A full-resolution source composite (overlay drawn on source pixels before resize) and a dedicated decoded-overlay cache are not built —target=sourcemaps through region+size on the already-resized image, and overlay bytes ride the existing OriginCache for http/s3.