Developing Extensions
Wolpi extensions are small programs that expose a set of hooks that Wolpi calls at specific points during its processing pipeline. By implementing these hooks, extensions can customize Wolpi's behavior in various ways.
Wolpi also provides a wolpi context object that gives extensions access to the Wolpi context, including configuration, logging, metrics, and some utility functions. Extensions can use this object to interact with parts of the Wolpi runtime environment.
Extensions in Wolpi undergo a specific lifecycle, from initialization to request processing to cleanup. Understanding this lifecycle is crucial for developing effective extensions that are performant and resource-efficient.
This page is organized so you can either read it from top to bottom or jump directly to the part you need:
- If you want to get an extension running quickly, start with Quickstart and Development Setup.
- If you are deciding which hook to implement, go to Hook Overview.
- If you need the full signatures and behavior of each hook, use the Hook Reference.
- If you are looking for runtime facilities such as
wolpi, logging, metrics or Java interop, see Runtime APIs. - If you are packaging extensions or choosing between JavaScript and Python, see Choosing a Language and Packaging and Project Layout.
Quickstart
If you want to get a first extension running before diving into the full API, start with a single-file extension. For running Wolpi locally while developing, see Development Setup.
Every extension needs to implement the info and cleanup hooks, so you can start with a minimal implementation of those and then expand it from there. The following examples show the minimal structure of a single-file extension in JavaScript and Python, respectively, using a very simple resolver that resolves identifiers like js-<name> or py-<name> to <name>.jpg files in a configured directory.
A single-file JavaScript extension is a single .js or .mjs file that exports its hooks.
The default export can be a plain object or another object instance such as a class instance.
A single-file Python extension can be as simple as a single .py file that defines its hooks as top-level functions.
If you prefer to use a class for your extension, you can provide a wolpi_extension factory method
that is called without arguments and should produce an instance of your extension:
Configure the extension in your wolpi.yml:
extensions:
- path: /app/extensions/hello-world.js # or hello-world.py
config:
baseDirectory: /images
Then launch the Wolpi container with the extensions, config and images directories mounted as volumes:
$ docker run \
-p 8080:8080 \
-v "$(pwd)/wolpi.yml:/app/wolpi.yml" \
-v "$(pwd)/extensions:/app/extensions" \
-v "$(pwd)/images:/images" \
ghcr.io/dbmdz/wolpi:latest
Drop an image (like this one into the images directory with the name test.jpg, and you should be able to access its info.json via
- JavaScript: http://localhost:8080/v3/js-test/info.json
- Python: http://localhost:8080/v3/py-test/info.json
Once you have one of these examples loading, use the Hook Overview and the Hook Reference to expand it to your liking.
Development Setup
We highly recommend using the official container image for developing extensions, since it already ships with all the dependencies required to run Wolpi with Python/JavaScript extensions (including those with third party dependencies). To do so, simply mount your configuration and the directory with your extensions into the container:
$ docker run \
-p 8080:8080 \
-p 4711:4711 \ # (1)!
-v "$(pwd)/wolpi.yml:/app/wolpi.yml" \ # (2)!
-v "$(pwd)/extensions:/app/extensions" \
ghcr.io/dbmdz/wolpi:latest
- For debugging, see below
- You can customize the configuration path inside the container by setting the
WOLPI_CONFIGenvironment variable, by default Wolpi will check/app/wolpi.ymlor/app/wolpi.yaml
Your wolpi.yml should specify the extension under the extensions prefix:
Core Model
Hooks
Wolpi extensions work by implementing one or more "hooks". A hook is a function that is called by
Wolpi at a specific point in its request processing pipeline. Most of the hooks are called when
processing client requests, where they can implement custom authorization,
resolving or override steps in the image processing.
There's also a hook for implementing customizations to the info.json response.
If multiple extensions implement the same hook, the behavior depends on the hook
and is described below.
IIIF Compliance
Wolpi's Extension API allows customization of pretty much every aspect of the image processing pipeline. With great power comes a bit of responsibility, though: Wolpi is designed to be a IIIF-compliant image server, and extensions must not violate the IIIF specification. This means that extensions must ensure that every part of the official IIIF Image API specification is adhered to, including parameter parsing, behavior, and error handling. On startup, Wolpi validates configured extensions against the official IIIF Image API test suite and refuses to start if validation fails. Validation is performed per extension in isolation, and unchanged extensions may be skipped on later startups if their cached validation result is still valid.
Practically, as an extension developer, this means:
- If possible, gate your custom logic behind syntax that does not conflict with IIIF syntax. For
example, if you want to implement a custom cropping behavior, use a custom syntax that is not
valid IIIF syntax (e.g.,
customCrop:x,y,w,hinstead ofx,y,w,h). - In some cases you may want to implement custom behavior that replaces standard IIIF behavior (e.g., a custom scaling algorithm). In these cases, ensure that your implementation adheres to the IIIF specification in terms of parameter parsing, behavior, and error handling. You have access to a IIIF Image API parameter parser in the Wolpi context that you can use to ensure compliance with the specification.
- Run your extension against the IIIF Image API conformance tests to ensure compliance. You can
do this with the
validatesubcommand of the Wolpi application: You can add-wto automatically run the tests whenever the extension code changes.
Extension Lifecycle
Extensions in Wolpi are kept in a pool after they have been loaded, so that they can be reused for
multiple subsequent requests without having to run expensive initialization code for each request.
Wolpi ensures that only one request is processed by any one extension instance at a time, so that
extensions do not need to worry about concurrency issues and can keep state in memory between
hook invocations and requests without having to secure it with locks or other synchronization
mechanisms. Each pooled extension instance runs setup once when the
instance is created, cleanup after each request it participated in, and
destroy when that instance is evicted from the pool or otherwise shut
down.
stateDiagram-v2
[*] --> setupHook
setupHook --> Pooled
Pooled --> RequestProcessing: Request Assigned
RequestProcessing --> cleanupHook
cleanupHook --> Pooled
Pooled --> destroyHook: Evicted/Shutdown
destroyHook --> [*]
click setupHook href "#setup-and-destroy-hooks"
click cleanupHook href "#cleanup-hook"
click destroyHook href "#setup-and-destroy-hooks"
During request handling, developers can safely assume that the request hooks are called in a specific order:
graph TB
A[authorize] --> B[resolve] --> C{Request Type?};
subgraph SA[Image Request]
direction TB
D[preProcessImage] --> E[preCrop];
E --> F[preScale];
F --> G[preRotate];
G --> H[preQuality];
H --> I[preFormat];
end
subgraph SB[info.json Request]
direction TB
K[augmentInfoJson];
end
I --> J[cleanup];
K --> J;
C --> SA;
C --> SB;
click A href "#authorize-hook"
click B href "#resolve-hook"
click D href "#preprocessimage-hook"
click E href "#image-processing-hooks"
click F href "#image-processing-hooks"
click G href "#image-processing-hooks"
click H href "#image-processing-hooks"
click I href "#preformat-hook"
click J href "#cleanup-hook"
click K href "#augmentinfojson-hook"
This means that you can maintain state between hook invocations and be sure that the state will always refer to the same request, as long as you clean it up in the end: Wolpi requires that every extension implements a cleanup hook. It is called after request processing for an extension instance that participated in handling the request. Use this hook to clear up any state that should not persist between requests. It's perfectly fine to have the hook do nothing if your extension does not accumulate any request-scoped state, but we mandate it anyway to avoid accidental state leaks (which are really difficult to debug).
Multiple Extensions and Hook Behavior
When configuring multiple extensions, it can happen that more than one extension implements the same hook. What happens in this case depends on the hook:
authorizeCalled in parallel until one returnsfalse, in which case the request is considered unauthorized and all other pending hook calls are canceled. If all returntrue, the request is authorized. If any of the extensions throws an error, the request fails with a error response.resolveCalled in parallel until one resolves to a valid image source, in which case all other pending hook calls are canceled. If none of the extensions resolve the identifier, Wolpi falls back to resolving it against the configured filesystem image base directory. If that also fails, the request fails with a404 Not Founderror. If any of the extensions throws an error and none of the others can resolve the identifier, the request fails with an error response, otherwise the error is logged and the first successful resolution is used.
Order is not guaranteed!
Since the authorize and resolve hooks are called in parallel, there is no guarantee
about the order in which the extensions are called. If you have multiple extensions that
implement these hooks, ensure that they do not depend on being called in a specific order and
ideally that there is no overlap in the identifiers they can handle.
If you need ordered behavior, e.g. to implement a custom "fallback resolver", consider combining
the logic into a single extension by declaring the other extensions as dependencies in your own
extension package, assuming they share a programming language.
-
augmentInfoJsonandpreProcessImageCalled in sequence, passing the result of each hook as the input to the next one. If any of them throw an error, the request fails with an error response. -
preCrop,preScale,preRotate,preQuality,preFormatCalled in sequence until one returns a non-null result. If none returns a non-null result, the standard implementation for that operation is used. If any of them throw an error, the request fails with an error response.
Hook Overview
The quickest way to decide which hook to implement is to start from the point in the request pipeline that you want to influence (see the Extension Lifecycle for the exact order of hooks).
setupfor running initialization code when an extension instance is createdinfo(required) for declaring basic metadata about the extensionskippableHooksfor declaring hooks that can safely be skipped for a given request, used to optimize processing by avoiding unnecessary hook callsauthorizefor allowing or denying access before any processing happensresolvefor mapping an identifier to an image sourceaugmentInfoJsonfor adding or changing fields ininfo.jsonpreProcessImagefor applying processing before the regular image pipeline startspreCrop,preScale,preRotate,preQualityfor replacing or augmenting a specific image processing steppreFormatfor taking over final image encodingcleanup(required) for clearing request-scoped state after processingdestroyfor cleaning up resources when an extension instance is terminated
Runtime APIs
In addition to the hooks that Wolpi calls, extensions have access to several APIs provided by Wolpi to interact with the runtime environment, perform logging and metrics, and even call Java APIs directly.
The wolpi Object
Extensions have access to a wolpi object, which provides access to the Wolpi context. This
includes the extension's configuration, which can be accessed via wolpi.config. In JavaScript,
this object is directly available in the global scope, while in Python, it is available as a
module wolpi that can be imported.
See the Runtime API Reference for a full reference of the available properties and methods on the wolpi object.
Logging from Extensions
You have access to an extension-specific logger instance via the wolpi.logger object. This logger provides
four logging levels: debug, info, warn and error. Each logging method accepts a message string
and an optional dictionary/object with key-value pairs that will be logged alongside the message for
structured logging. The base logger is already scoped to your extension, i.e. it logs under
dev.mdz.wolpi.extension.<extension-name>, and wolpi.logger.getLogger("foo") creates a sub-logger under
dev.mdz.wolpi.extension.<extension-name>.foo.
See the Runtime API Reference for more details on the available logging APIs.
Custom Metrics from Extensions
You can register custom metrics from your extensions using the wolpi.metrics object. This object
provides methods to create counters, gauges and timers. Each method accepts a name for the metric,
an optional description, an optional unit (except for timers, those are always measured in seconds)
and an optional dictionary/object with labels to attach to the metric. These metrics will
then be available in the Wolpi observability reference alongside the built-in
metrics, and you can use them to monitor your extension's behavior and performance.
See the Runtime API Reference for more details on the available metrics APIs.
wolpi: Modules
Wolpi provides a few built-in polyfill modules that can be imported by JavaScript extensions:
wolpi:fs
Provides a subset of the Node.js fs module for synchronous file system operations
import { readFileSync, readDirSync } from 'wolpi:fs';
const fileContents = readFileSync('/path/to/file.txt');
for (const file of readDirSync('/path/to/directory')) {
console.log(file);
}
wolpi:fetch
Provides a synchronous fetch function for making HTTP requests. Import it as the module's
default export:
import fetchSync from 'wolpi:fetch';
const response = fetchSync('https://example.com/api/data');
if (response.ok) {
const data = response.json();
console.log(data);
} else {
console.error(`Request failed with status ${response.status}`);
}
Working with Java Classes from Extensions
Extensions have free access to all Java classes on the Wolpi classpath and can use them as needed to implement their functionality. To do so, use the Graal Polyglot API (Python/JavaScript) to get a reference to a Java class and then call its static methods or create instances as needed.
Image hooks also receive a ready-made Java object instance for a VImage object
that they can call APIs on to perform image processing. This class comes from the
vips-ffm Java bindings for libvips, refer to the JavaDoc to learn
more about the available APIs for image processing. Some of the methods on the VImage class require a memory arena to be passed in, which is available as wolpi.vipsArena in the extension context and should be used in these instances.
- Here we create a new
VImageobject with some text to use as a watermark. We need to pass thewolpi.vipsArenamemory arena to theVImage.textmethod to create the image, since the vips bindings we use need to do an allocation for that.
- Here we create a new
VImageobject with some text to use as a watermark. We need to pass thewolpi.vipsArenamemory arena to theVImage.textmethod to create the image, since the vips bindings we use need to do an allocation for that.
Choosing a Language
Fastest: JavaScript Extensions
Wolpi uses GraalVM's JavaScript runtime to run JavaScript extensions. This runtime
is fully compliant with the ECMAScript 2024 specification and supports most modern JavaScript
features (including async/await, though see the note below about asynchronous hooks). For more
details, refer to the GraalJS documentation.
Note, however, that the runtime does not provide Node.js APIs or a DOM/browser environment, which
limits interoperability with a large chunk of the npm ecosystem. A few standards-based APIs are
enabled by the runtime (for example TextEncoder/TextDecoder and Worker), and Wolpi provides
a few synchronous Wolpi-specific polyfills for filesystem and HTTP APIs for extension
authors.
JavaScript extensions must be written as ES modules with either:
- a default export with the hooks as entries in an object, including custom object instances such as class instances
- or named exports for each hook.
Async?
Wolpi invokes JavaScript hooks synchronously and expects them to return their final result
immediately. Declaring a hook as async is not rejected at load time, but it will not work:
Wolpi does not await the returned promise and will fail when it tries to coerce the hook result
to the expected host type. Top-level await in modules is supported, but hook bodies themselves
must behave synchronously. If you do have a use case for asynchronous behavior that we might not
have been aware of, please open an issue on the Wolpi GitHub repository and we may consider it
for a future iteration of the extension API.
JavaScript extensions are generally much more performant than Python extensions, due to the simpler runtime and the lack of a standard library. If latency is a concern for your use case, we recommend implementing your extension in JavaScript if possible. This is not to say that Python's worse performance is prohibitive for all use cases, but it's something to keep in mind when choosing a language for your extension. If you have a use case that requires Python's rich ecosystem of libraries and are okay with potentially higher latency, Python extensions are a great choice and Wolpi's GraalPy runtime provides excellent compatibility with the Python ecosystem, including packages with native code.
Richest Ecosystem: Python Extensions
The Python runtime used in Wolpi is GraalPy, which is a fully compliant Python 3.12
runtime. Python extensions have full access to all parts of the Python 3.12 standard library, including
things like urllib.request for making HTTP requests, os/pathlib for file system access and
even sqlite3 for local databases.
Python extensions can make use of dependencies, including dependencies with native code. However, note that not all packages on PyPI are supported by the GraalPy runtime. A good source to check for compatibility is the registry of compatible packages available from the GraalPy website. Note that many packages that are noted as passing less than 100% of their test suite might still work fine for your use case; it's usually worth the time to evaluate a dependency using a GraalPy standalone REPL.
Native Extensions
If the notes in the table on the compatibility page mention patches being applied by GraalPy,
you need to install GraalPy locally and set the wolpi.packaging.python-executable to the path
to your GraalPy Python executable. This ensures that any necessary patches are applied when
installing the extension package. By default, your system's default Python 3 interpreter is used.
If possible, use the container, it has everything needed included by default.
Packaging and Project Layout
Wolpi extensions can be kept as single files, as shown in the Quickstart, or packaged as regular JavaScript and Python packages.
A JavaScript extension can also be a standard npm package. The package.json file must have an
exports field with a string value that points to the entry point of the extension. The entry
point must export the hooks in the same way as a single-file extension. The package can either be
local or published to npm or another registry.
The package can declare dependencies. Note that the Wolpi JavaScript runtime provides neither Node nor Browser-specific APIs, which limits the set of usable packages to those that do not depend on APIs from either.
A Python extension can also be a standard Python package, either in a local directory or from a
package registry (defaults to PyPI, but custom indices can be configured). The package must define
a wolpi entry point in its pyproject.toml. This entry point must point to a callable that
returns a dictionary of hooks, or an object that has the hooks as methods. The directory layout
of the package can either be
"src"-style or "flat"
(i.e. src/<pkg> or just <pkg>).
| ./pyproject.toml | |
|---|---|
- We depend on the
redispackage from PyPI. The GraalPy compatibility table states that the test suite only passes 65% of the tests, but most of the basic functionality works fine. It's usually worth spinning up a standalone GraalPy interpreter and playing around with a package to see if it's working as intended. - This part is crucial for a packaged Python extension: We define a
wolpientry point that points to theextensioncallable in thehello_py_pkg.extensionmodule. This callable must return an object with the extension hooks as methods, or a dictionary with the hooks as entries.
- Since we maintain a bunch of state in this extension, it's a good idea to use a class to hold the state and the extension behavior together.
- Our state consists of:
- A Redis client that we use to do our lookup in the auth set. This client will live for as long as the extension object lives, i.e. across many requests
- A counter metric that we use to monitor how many internal clients were requesting full-resolution images. We could also have recreated this metric during every request (metrics are cached, i.e. this would have been cheap), but it's cleaner to keep it around
- A reference to our extension logger so we can see what's going on in the logs
- And finally: a request-scoped state variable. The hooks in this extension are called in
order, and one instance is only ever used for a single request at a time, i.e. it is safe
to set up a state variable at any point in the pipeline and then refer to it from a later
point, as long as we don't forget to clean up after every request with the
cleanuphook
- We set our request-scoped state variable in the
authorizehook, where we have access to the client request's HTTP headers, so we can know later on if the user identified as an internal user. - Here we check the request-scoped state variable that we have previously set in
authorize, since we no longer have access to the HTTP headers at this point - And finally, we must reset the state variable so the next request gets a clean state
Error Handling
By default, any exception that is raised in an extension hook will be logged along with a (filtered to reduce noise) stack trace. Depending on the hook where the exception occurred, Wolpi will either return an HTTP 500 response to the client, or skip the extension and continue processing with the next extension (if multiple extensions are configured).
If you need to return an HTTP error response to the client from within an extension hook, you can raise a special exception from your extension hooks:
type HttpStatusError = {
message: string;
status: number;
details?: { [key: string]: any };
};
export default {
// info/cleanup omitted for brevity
resolve: (identifier) => {
if (identifier === 'broken-identifier') {
throw {
"message": "Got a broken identifier",
"status": 400,
"details": {
"cause": "identifier had 'broken' in it."
}
};
}
}
// ...
};
class HttpStatusError(Exception):
"""
Exception to return an HTTP response with a status code, a message and an
optional JSON body.
"""
#: Error message associated with the exception
message: str
#: HTTP status code associated with the exception
status: int
#: Optional JSON body to include in the response body instead of the message
details: dict | None
def __init__(self, message: str, status: int, details: dict | None = None):
self.message = message
self.status = status
self.details = details
super().__init__(message)
If Wolpi encounters such an exception, it will return a corresponding HTTP response to the client,
except for the resolve hook, where it will skip the extension and continue processing with the
other configured resolving extensions. Only if no other extension is able to resolve the identifier,
Wolpi will return the error response from the first extension that raised such an exception.
Otherwise, the exception is logged and Wolpi continues processing with the result of the other
successful extension. If the exception contains details, that object becomes the response body;
otherwise Wolpi returns a body with a single message field.
Debugging and Live Reload
Debugging Extensions
Wolpi provides support for the "Debug Adapter Protocol" (DAP), which allows you to connect a debugger to it and set breakpoints, step through code, and inspect variables inside your extensions.
To enable debugging, set the wolpi.extension-debug section in your config:
Then, open the directory with your extensions in Visual Studio Code and create the following
launch configuration in .vscode/launch.json:
| .vscode/launch.json | |
|---|---|
Start up Wolpi, then start the debugger in VS Code using the "Attach to JS Extensions" or "Attach to Python Extensions" configuration. You should see the application threads and be able to set breakpoints and step through your extension code.
Debugging with the Wolpi container
Breakpoints only work if the path to the extension inside the container matches the path on the host machine. To achieve this, mount the directory with your extensions into the container at the same absolute path as on your host system. This is a limit of the Graal Debug Adapter implementation that does not allow remapping paths at the moment.
Live Reload for local extensions
To make development easier, Wolpi supports installing local extensions with live reload enabled. This allows you to make changes to your extension code and have them automatically picked up by Wolpi without having to restart the entire Wolpi application.
Simply set the live-reload: true option in the extension definition in wolpi.yml:
This feature comes with some caveats:
- Only changes to the source files are picked up. If you make modifications to your
package.jsonorpyproject.toml, you will have to restart Wolpi to have them take effect. - If you want live reload for single-file extensions mounted into a container, you must mount the parent directory of the extension into the container, and not just the file itself to get live reloading to work
- The
npmexecutable used must be at least version 10.
You need to have a standalone GraalPy installation for this to work and configure
Wolpi to use it for installing Python extensions, either by putting the graalpy executable on
your $PATH or by setting the wolpi.packaging.python-executable configuration option. The
major version of GraalPy must match the one used in Wolpi (currently: 25).
The official Wolpi container image already comes with both npm and GraalPy installed, so you can
use that as a base for your development environment if needed. For easy local installation of these
dependencies, we recommend using a tool like mise.
Hook Reference
info Hook
Every extension must implement the info hook to return basic information about itself, such as
its name and description.
Do not use this hook for initialization code!
The info hook is called by Wolpi on startup to gather information about the extension.
It is executed in a different context than the one used when processing requests, so any
modifications to the extension's state in this hook will not be visible during request processing.
If you need to run initialization code for your extension, use the setup hook
instead.
function info(): {
apiVersion: 1; // (1)!
// Name of the extension, should be unique among all enabled extensions
name: string;
// A brief description of what the extension does
description: string;
};
- This is currently hardcoded to
1, as there is only one version of the extension API. In the future, breaking changes to the API will increment this number to ensure that we can distinguish older extensions from newer ones and prevent breakage
class ExtensionInfo(TypedDict): # (1)!
apiVersion: Literal[1] # (2)!
name: str
description: str
def info() -> ExtensionInfo: ...
- In Python, you can either return a
dictwith the required entries as specified by thisTypedDictdefinition or a class with the same attributes as instance fields. This goes for all of the subsequentTypedDictdefinitions in this documentation. - This is currently hardcoded to
1, as there is only one version of the extension API. In the future, breaking changes to the API will increment this number to ensure that we can distinguish older extensions from newer ones and prevent breakage
cleanup Hook
Every extension must implement the cleanup hook, even if it does nothing. This hook is called
after request processing for any extension instance that participated in the request and must be used
to clean up any state that was accumulated during the processing of a request and should not persist
between requests.
setup and destroy Hooks
Use the setup hook if you need to run potentially expensive initialization code for your extension
that shouldn't be run during a request-response cycle, for example compiling regexes, setting up
database connections, loading an AI model, etc.
If you need to clean up resources that were opened/allocated in setup, implement the destroy hook.
This hook is called whenever an extension instance is shut down.
Don't confuse cleanup and destroy!
The cleanup hook is called after request processing to clean up request-scoped state,
while the destroy hook is called when the extension instance is being shut down
to clean up resources that were allocated in setup. destroy is optional, whereas
cleanup is mandatory.
skippableHooks Hook
Wolpi implements several fast paths in its image processing pipeline, based on the nature of the incoming image requests, e.g. for fast thumbnail or tile requests.
If an extension implements one of the available image processing hooks (preProcessImage, preCrop, preScale, preRotate, preQuality, preFormat) Wolpi will not be able to determine if those fast paths can be taken (since the extension might customize the default behavior for the scaling operation) and opts for a path that allows full customization, but can be a lot slower for some operations.
This is often unnecessary, since many extensions will likely only run their custom processing logic in the presence of a certain parameter, and won't run for the majority of requests. Extensions can specify when this is the case by implementing the skippableHooks hook, which returns the set of hooks that can be skipped when processing a given IIIF Image API request. If an image processing hook is part of this set for a request, it will not be run at all for that request. By default, every hook that is not implemented by an extension is in the skippable set, and the result of that hook will be added to the set. Currently only the presence of the image processing hooks has an impact on the image processing pipeline.
authorize Hook
The authorize hook is called before any processing is done for a request to determine if the request
is authorized to access the requested image. The hook receives the identifier, the request headers and
a string with the original client IP that made the request. If the request is authorized, the hook
must return true. If the request is not authorized, the hook must return false.
If there are multiple extensions that implement the authorize hook, Wolpi will call them in parallel until all return true or one of them returns false. If all extensions return true, the request is considered authorized.
resolve Hook
The resolve hook is called to resolve an image identifier to an image source. The hook receives
the image identifier as well as caching information from the client (ETag and Last-Modified headers,
if present on the request) and must return a value that describes how to access the image, if it
can be resolved by this extension. Additionally, the return value can contain metadata about the image
(such as width, height, format, etc.) to avoid having Wolpi read the image from the source just to extract
this information for info.json requests.
If you return a FileSystemResolvedImage, Wolpi will automatically fill the cacheInfo with the
modification date of the resolved file, if it is not already set by your extension. If the image has
cacheInfo set (either by Wolpi or by the extension), Wolpi will automatically check it against the
incoming request headers (If-Modified-Since and If-None-Match) and decide if it can return a
HTTP 304 "Not Modified" response. If you want to override this behavior, you can return a
SourceNotModified to force a HTTP 304 response.
/**
* Set of metadata about the image that can be provided by the
* resolver to avoid having Wolpi read the image just to extract
* this information for `info.json` requests
*/
interface ImageInfo {
nativeSize: {
width: number;
height: number;
};
/**
* Sizes that are directly encoded in the image, e.g. JPEG2k
* or TIFF layers.
*/
sizes?: Array<{
width: number;
height: number;
}>;
/**
* Tile sizes that are directly encoded in the image, e.g.
* JPEG2k or TIFF tiles.
*/
tileSizes?: Array<{
width: number;
height: number;
scaleFactors: number[];
}>;
}
/** Optional HTTP cache metadata associated with a resolved image. */
interface CacheInfo {
eTag?: string;
/** Last-modified timestamp as a JS `Date` or an ISO-8601 string. */
lastModified?: Date | string;
}
/** An image file in a file system accessible to Wolpi */
interface FilesystemResolvedImage {
path: string;
imageInfo?: ImageInfo;
cacheInfo?: CacheInfo;
}
/**
* A data blob containing the raw (still encoded) image data,
* will be decoded by libvips
*/
interface BinaryResolvedImage {
rawData: Uint8Array | ArrayBufferView;
imageInfo?: ImageInfo;
cacheInfo?: CacheInfo;
}
/**
* An image accessible via HTTP(S), optionally with custom request
* headers (e.g. for auth)
*/
interface HttpResolvedImage {
url: string;
headers?: Record<string, string>;
imageInfo?: ImageInfo;
cacheInfo?: CacheInfo;
}
/**
* A custom data source that libvips will read from using the
* provided callbacks, can be more efficient for reading very
* large images from e.g. databases or object storage systems
* that do not support HTTP.
*/
interface CustomSourceResolvedImage {
/**
* `whence` denotes the position to seek from:
* 0 (beginning of file), 1 (current position)
* or 2 (end of file)
*/
onSeek(offset: number, whence: number): number;
/**
* The returned buffer will be copied, feel free to
* reuse internal buffers for subsequent calls
*/
onRead(length: number): Uint8Array | ArrayBufferView;
imageInfo?: ImageInfo;
cacheInfo?: CacheInfo;
}
/**
* Indicate that the source has not been modified since it was
* last accessed by the client, determined from the caching headers
*/ passed to the resolving hook
interface SourceNotModified {
notModified: true;
imageInfo?: ImageInfo;
cacheInfo?: CacheInfo;
}
/** Union type of all supported resolving types */
type ResolvedImage =
FilesystemResolvedImage |
BinaryResolvedImage |
HttpResolvedImage |
CustomSourceResolvedImage |
SourceNotModified;
/** Finally, the resolve hook itself */
function resolve(
identifier: string,
clientETag?: string,
clientLastModified?: string
): ResolvedImage | null | undefined;
# Size of an image (layer) in pixels
class ImageSize(TypedDict):
width: int
height: int
# Size of a tile encoded in an image, with available scaling factors
class TileSize(TypedDict):
width: int
scaleFactors: list[int]
height: NotRequired[int | None]
# Optional set of metadata about the image that can be provided by the
# resolver to avoid having Wolpi read the image just to extract
# this information for `info.json` requests
class ImageInfo(TypedDict):
format: NotRequired[str | None]
nativeSize: ImageSize
sizes: list[ImageSize]
tileSizes: list[TileSize]
# Optional caching information about the image source.
class CacheInfo(TypedDict):
eTag: NotRequired[str]
lastModified: NotRequired[datetime.datetime | str]
# An image file in a file system accessible to Wolpi
class FilesystemResolvedImage(TypedDict):
path: str
imageInfo: NotRequired[ImageInfo]
cacheInfo: NotRequired[CacheInfo]
# A data blob containing the raw (still encoded) image data,
# will be decoded by libvips
class BinaryResolvedImage(TypedDict):
rawData: bytes | bytearray
imageInfo: NotRequired[ImageInfo]
cacheInfo: NotRequired[CacheInfo]
# An image accessible via HTTP(S), optionally with custom request
# headers (e.g. for auth)
class HttpResolvedImage(TypedDict):
url: str
headers: NotRequired[Mapping[str, str]]
imageInfo: NotRequired[ImageInfo]
cacheInfo: NotRequired[CacheInfo]
# A custom data source that libvips will read from using the
# provided callbacks, can be more efficient for reading very
# large images from e.g. databases or object storage systems
# that do not support HTTP with byte-range requests
# This is best returned as an actual object, not a dict.
class CustomSourceResolvedImage(Protocol):
# `whence` denotes the position to seek from: 0 (beginning of file),
# 1 (current position) or 2 (end of file)
def onSeek(self, offset: int, whence: int) -> int:
...
# The returned buffer will be copied, feel free to reuse internal
# buffers for subsequent calls
def onRead(self, length: int) -> bytes | bytearray:
...
def __getattr__(self, name: str) -> Any: ...
# Indicate that the source has not been modified since it was
# last accessed by the client, determined from the caching headers
# passed to the resolving hook
class SourceNotModified(TypedDict):
notModified: Literal[True]
imageInfo: NotRequired[ImageInfo]
cacheInfo: NotRequired[CacheInfo]
type ResolvedImage = Union[
FilesystemResolvedImage,
BinaryResolvedImage,
HttpResolvedImage,
CustomSourceResolvedImage,
SourceNotModified
]
def resolve(
identifier: str,
client_etag: str | None,
client_last_modified: str | None
) -> ResolvedImage | None: ...
If there are multiple extensions that implement the resolve hook, Wolpi will call them in parallel until one of them returns a valid result. If none of them do, Wolpi falls back to resolving the identifier against the configured filesystem image base directory.
augmentInfoJson Hook
The augmentInfoJson hook is called when generating the info.json response for an image. The hook
receives the identifier and the current info.json object as parameters and can return a new object
with modifications or additions to the info.json response.
Return a new object from the hook!
The augmentInfoJson hook should return a new object with the modifications or additions
to the info.json response instead of mutating the passed-in object directly. Treat the
incoming object as read-only and return a modified copy.
If there are multiple extensions that implement the augmentInfoJson hook, Wolpi will call them
in the order they are configured, passing the result of each hook to the next one.
preProcessImage Hook
The preProcessImage hook is called before any other image processing is done. The hook receives
a VImage object that gives access to libvips operations on the image, as well as metadata about
the image and the Image API request. It must return a new VImage object that represents the processed image,
or a null/None value to indicate that no processing was done (see below) for details on how
to interact with VImage). The returned image must have the same dimensions as the input image;
if it does not, Wolpi logs a warning and ignores the result.
function preProcessImage(
// VImage is a Java host object, see below for details on how to work with it
image: VImage,
identifier: string,
// see resolving hook types for ImageInfo type
imageInfo: ImageInfo,
request: {
identifier: string;
version: IIIFVersion;
cropSpec: string;
sizeSpec: string;
rotationSpec: string;
qualitySpec: string;
formatSpec: string;
}
): VImage | null | undefined;
class ImageApiRequest:
identifier: str
version: IIIFVersion
cropSpec: str
sizeSpec: str
rotationSpec: str
qualitySpec: str
formatSpec: str
def pre_process_image(
# VImage is a Java host object, see below for details on how to work with it
image: VImage,
identifier: str,
# see resolving hook types for ImageInfo type
image_info: ImageInfo,
request: ImageApiRequest
) -> VImage | None: ...
Image Processing Hooks
With the preProcessImage, preScale, preCrop, preRotate and preQuality hooks,
extensions can implement custom behavior for the respective image operations. Each hook receives
the VImage at the current step of the processing pipeline, metadata about the image and the Image
API request. The hook must return a new VImage object that represents the processed image, or a
null/None value to indicate that no processing was done and Wolpi should continue with the
standard implementation (see below) for details on how to interact with VImage.
For preProcessImage, the returned image must preserve the input dimensions; results with a
different width or height are logged and ignored.
type ImageProcessingHook = (
// VImage is a Java host object, see below for details on how to work with it
image: VImage,
identifier: string,
// see resolving hook types for ImageInfo type
imageInfo: ImageInfo,
request: {
identifier: string;
version: IIIFVersion;
cropSpec: string;
sizeSpec: string;
rotationSpec: string;
qualitySpec: string;
formatSpec: string;
}
): VImage | null | undefined;
const preProcessImage: ImageProcessingHook;
const preScale: ImageProcessingHook;
const preCrop: ImageProcessingHook;
const preRotate: ImageProcessingHook;
const preQuality: ImageProcessingHook;
class ImageApiRequest:
identifier: str
version: IIIFVersion
cropSpec: str
sizeSpec: str
rotationSpec: str
qualitySpec: str
formatSpec: str
type ImageProcessingHook = Callable[
[
VImage, # image
str, # identifier
ImageInfo, # image_info
ImageApiRequest # request
],
VImage | None
]
pre_process_image: ImageProcessingHook
pre_scale: ImageProcessingHook
pre_crop: ImageProcessingHook
pre_rotate: ImageProcessingHook
pre_quality: ImageProcessingHook
If multiple extensions implement preProcessImage, Wolpi will call all of them in configuration
order, passing the output of each hook to the next one. The preCrop, preScale, preRotate and
preQuality hooks behave differently: Wolpi calls them in configuration order and uses the first
non-null result. If all extensions return null/None, Wolpi will continue with the standard
implementation for that operation.
preFormat Hook
The preFormat hook is called before the image is encoded to the requested output format. The hook
has the same parameters as the above image processing hook, but instead of a VImage object, it
must return an EncodedImage object that represents the encoded image data, or a null/None value
to indicate that no encoding took place.
interface EncodedImage {
data: Uint8Array | ArrayBufferView | ByteBuffer; // (1)!
contentType: string;
extraHeaders?: Record<string, string[]>;
}
function preFormat(
image: VImage,
identifier: string,
imageInfo: ImageInfo,
request: {
identifier: string;
version: IIIFVersion;
cropSpec: string;
sizeSpec: string;
rotationSpec: string;
qualitySpec: string;
formatSpec: string;
}
): EncodedImage | null | undefined;
ByteBufferis the Java typejava.nio.ByteBuffer
Runtime API Reference
The Wolpi Context Object
interface ExtensionGuestContext { // (1)!
// Configuration object/dict for the extension, if present
config: Record<string, any> | undefined;
// Version specifier for Wolpi
wolpiVersion: string;
// Version specifier for the extension
extensionVersion: string;
// Logger instance for logging messages
logger: ExtensionLogger;
// Metrics object to register custom metrics
metrics: ExtensionMetrics;
// A memory arena that can be passed to vips-ffm APIs for creating
// new images, should be treated as an opaque handle that is only
// forwarded to vips-ffm, __do not__ store a reference to it or try to
// manipulate it directly
vipsArena: Arena;
// This object provides parsing methods for all parts of the IIIF
// Image API request, use this if you want to implement custom behavior
// based on the official syntax
imageRequestParser: ImageRequestParser;
httpClient: HttpClient; // (2)!
baseUri?: string;
}
interface ExtensionLogger {
getLogger(name: string): ExtensionLogger;
debug(message: string, keyVals?: { [key: string]: unknown }): void;
info(message: string, keyVals?: { [key: string]: unknown }): void;
warn(message: string, keyVals?: { [key: string]: unknown }): void;
error(message: string, keyVals?: { [key: string]: unknown }): void;
}
interface ExtensionMetrics {
counter(
name: string,
unit?: string,
description?: string,
labels?: { [key: string]: string }
): { increment(value?: number): void };
gauge(
name: string,
unit?: string,
description?: string,
labels?: { [key: string]: string }
): { set(value: number): void };
timer(
name: string,
description?: string,
labels?: { [key: string]: string }
): {
record<T>(fn: () => T): T;
start(): { stop(): void };
};
}
interface ImageRequestParser {
parseRegion(spec: string, sourceSize: ImageSize): CropRectangle;
parseSize(version: IIIFVersion | 'v2' | 'v3', spec: string, sourceSize: ImageSize): {
width: number;
height: number;
};
parseRotation(spec: string): {
degrees: number;
mirror: boolean;
};
parseQuality(spec: string): 'color' | 'gray' | 'bitonal';
toCanonicalForm(request: ImageApiRequest, sourceSize: ImageSize): ImageApiRequest | null;
}
- In JavaScript, the
wolpiobject of typeExtensionGuestContextis available in the global scope for all extensions. HttpClientis the Java typejava.net.http.HttpClient
The context is available as a module wolpi that can be imported in any extension module:
The module has the following structure, with the ExtensionGuestContext class representing the shape of the wolpi object imported from the module:
class ExtensionGuestContext: # (1)!
# Configuration object/dict for the extension, if present
config: dict[str, Any] | None
# Version specifier for Wolpi
wolpiVersion: str
# Version specifier for the extension
extensionVersion: str
# Logger instance for logging messages
logger: ExtensionLogger
# Metrics object to register custom metrics
metrics: ExtensionMetrics
# A memory arena that can be passed to vips-ffm APIs for creating
# new images, should be treated as an opaque handle that is only
# forwarded to vips-ffm, __do not__ store a reference to it or try to
# manipulate it directly
vipsArena: Arena
# This object provides parsing methods for all parts of the IIIF
# Image API request, use this if you want to implement custom behavior
# based on the official syntax
imageRequestParser: ImageRequestParser
httpClient: HttpClient
baseUri: str | None
class ExtensionLogger:
def getLogger(self, name: str) -> ExtensionLogger: ...
def debug(self, message: str, keyVals: Mapping[str, Any] | None = None) -> None: ...
def info(self, message: str, keyVals: Mapping[str, Any] | None = None) -> None: ...
def warn(self, message: str, keyVals: Mapping[str, Any] | None = None) -> None: ...
def error(self, message: str, keyVals: Mapping[str, Any] | None = None) -> None: ...
class ExtensionMetrics:
def counter(
self,
name: str,
unit: str | None = None,
description: str | None = None,
labels: Mapping[str, str] | None = None
) -> CounterMetric: ...
def gauge(
self,
name: str,
unit: str | None = None,
description: str | None = None,
labels: Mapping[str, str] | None = None
) -> GaugeMetric: ...
def timer(
self,
name: str,
description: str | None = None,
labels: Mapping[str, str] | None = None
) -> TimerMetric: ...
class CounterMetric:
def increment(self, value: float | None = None) -> None: ...
class GaugeMetric:
def set(self, value: float) -> None: ...
class RunningTimer:
def stop(self) -> None: ...
T = TypeVar("T")
class TimerMetric:
def record(self, fn: Callable[[], T]) -> T: ...
def start(self) -> RunningTimer: ...
class ImageRequestParser:
def parseRegion(self, spec: str, source_size: ImageSize) -> CropRectangle: ...
def parseSize(self, version: IIIFVersion | Literal['v2', 'v3'], spec: str, source_size: ImageSize) -> ImageSize: ...
def parseRotation(self, spec: str) -> Rotation: ...
def parseQuality(self, spec: str) -> Literal['color', 'gray', 'bitonal']: ...
def toCanonicalForm(self, request: ImageApiRequest, source_size: ImageSize) -> ImageApiRequest | None: ...
class CropRectangle:
x: int
y: int
width: int
height: int
class ImageSize:
width: int
height: int
class Rotation:
degrees: float
mirror: bool
The wolpi:fs JavaScript module
/* Reads the entire contents of a file into a `Uint8Array`. */
function readFileSync(path: string): Uint8Array;
interface DirEnt {
name: string;
parentPath?: string;
isFile: boolean;
isDirectory: boolean;
isSymbolicLink: boolean;
isOther: boolean;
}
/* Reads the contents of a directory */
function readDirSync(path: string): DirEnt[];
interface Stats {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
isOther(): boolean;
size: number;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
}
/* Provides basic file metadata */
function statSync(path: string): Stats;
function lstatSync(path: string): Stats;
/* Throws if the file or directory at `path` is not accessible with the given
mode (default: `'r'` for read access, other values include `w` and `x` and
combinations thereof). */
function accessSync(path: string, mode?: string): void;
The wolpi:fetch JavaScript module
interface FetchResponse {
ok: boolean;
array(): Uint8Array;
arrayBuffer(): ArrayBuffer;
text(): string;
status: number;
headers: Record<string, string>;
json<T = unknown>(): T;
}
/// Makes a synchronous HTTP request to the given URL with the specified options.
function fetchSync(
url: string,
options?: {
body?: string | Uint8Array;
method?: string;
headers?: Record<string, string>;
}
): FetchResponse;