vidocq-runtime-spi exposes the interfaces an extension implements to contribute to the runtime. It is the only module a user-side extension must declare in its Java Modules requires.
Coordinates
Artefact |
|
Java module |
|
Source |
|
Public surface
List indexed from the existing sources:
| Type | Role |
|---|---|
|
The extension point — lifecycle interface with |
|
Handed to |
|
String-property view passed to |
|
Typed configuration API aligned with MicroProfile Config concepts — |
|
Pluggable configuration sources (analogous to MicroProfile), also discovered via |
|
Typed conversion of configuration values. |
|
Application-side entry points — the class run inside the Vauban application layer by |
|
The application’s module layer, when Vidocq boots it in one of its own — |
|
Adds a section to the startup report — |
|
What a contributor reads — |
|
Where a contributor writes — |
|
The level of the report ( |
|
The startup report of the running boot, read-only (Reading the startup report NEW). |
|
The text-only records of that view. |
The dev console panel SPI, DevConsolePanel and the types it writes, is a module of its own, vidocq-runtime-devconsole-spi NEW (Dev console panels NEW).
Writing an extension — skeleton
An extension overrides only the hooks it needs; every lifecycle method has an empty default implementation.
package com.example.greeting;
import io.vidocq.runtime.spi.VidocqExtension;
import io.vidocq.runtime.spi.VidocqConfiguration;
import io.vidocq.runtime.spi.ExtensionContext;
public class GreetingExtension implements VidocqExtension {
private String prefix;
@Override
public String name() {
return "greeting";
}
@Override
public void configure(VidocqConfiguration config) {
// called before CDI boot — read your keys
this.prefix = config.property("vidocq.greeting.prefix", "Hello");
}
@Override
public void onStart(ExtensionContext ctx) {
// the CDI container is up — resolve beans, mount handlers
Greeter greeter = ctx.container().select(Greeter.class);
greeter.setPrefix(prefix);
}
@Override
public java.util.Set<String> configKeys() {
return java.util.Set.of("vidocq.greeting.prefix");
}
}
Java Modules declaration in module-info.java:
module com.example.greeting {
requires io.vidocq.runtime.spi;
provides io.vidocq.runtime.spi.VidocqExtension
with com.example.greeting.GreetingExtension;
}
That is all. At startup the runtime’s ExtensionLoader discovers the provides directive through java.util.ServiceLoader, sorts all extensions by ascending priority(), and VidocqBootstrap drives the lifecycle: configure → beforeStart → onStart, then onStop in reverse order at shutdown.
Ordering with priority()
Priorities express real ordering constraints between extensions. The built-in HTTP stack illustrates the idiom: the Chappe engine prepares the server at priority 100, contributors such as the Cassini REST extension mount their handlers around priority 500, and the listener bootstrap opens the sockets last at priority 10 000. Pick a value between the extensions you must run after and before; unrelated extensions can keep the default 1000.
Configuration keys audit
Declaring your keys in configKeys() is optional but recommended: the runtime’s ConfigKeyAudit compares every configured vidocq. key against the union of declared keys and warns at startup about keys nobody consumes — the typical typo that otherwise silently keeps the default. The warning carries the code VIDOCQ-CFG-003 NEW. Entries ending in declare a prefix ("vidocq.greeting.*").
Listing the application’s files NEW
Under vidocq:dev, vidocq:run, the launcher vidocq:package writes by default, and after Vidocq.run re-layers a trampoline’s application, Vidocq boots the application in a module layer of its own. Its archives are then off the JVM’s class and module paths: the thread context class loader serves each of their files by name, but no class loader lists a directory of them, and getResources("db/migration") finds nothing. An extension that scans a directory of the application lists it from the layer, then reads each file by name:
Optional<ModuleLayer> layer = ApplicationLayer.current();
if (layer.isPresent()) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
for (String name : ApplicationLayer.list(layer.get(), "db/migration")) {
try (InputStream in = loader.getResourceAsStream(name)) {
// name is a resource name, such as db/migration/V1__init.sql
}
}
} else {
// no layer of its own (the application module in the boot layer, a class-path launch, a unit test):
// the class loader lists the directory itself
}
current() reads the layer at each call, so a dev reload’s new layer is the one it returns. It is empty when the application has no layer of its own, and when no Vidocq runtime is on the path, as in an extension’s unit tests. list walks the modules of that layer only, directories and jars alike, and returns the files under the directory, at any depth, sorted. The Flyway and Liquibase backends list their classpath: locations this way (Scripts in the application layer).
Reading the launch mode NEW
ExtensionContext.launchMode() returns the launch mode Vidocq resolved for this boot: LaunchMode.DEV, TEST or PROD, from the package io.vidocq.runtime.spi.report. An extension reads it to offer what only makes sense while developing, rather than reading vidocq.profile itself:
@Override
public void onStart(ExtensionContext ctx) {
if (ctx.launchMode() == LaunchMode.DEV) {
// what only makes sense while developing
}
}
It is an observation, and the same for every extension of a boot: the reloads of the dev loop read it again. launchMode() is a default method that returns PROD, so an ExtensionContext written by hand, such as a test fake, compiles unchanged.
Contributing to the startup report NEW
The startup report that ends every boot has a section for every library that contributes one: what it found, what it serves, what is wrong with it. The contract is the package io.vidocq.runtime.spi.report of this module, so a contributor needs requires io.vidocq.runtime.spi, like an extension, and nothing else: never vidocq-runtime-core, which implements it.
A contributor is normally a Vidocq extension. An extension that also implements StartupReportContributor is found with no second declaration, and its section comes first, in the order the extensions start:
public class GreetingExtension implements VidocqExtension, StartupReportContributor {
// name(), onStart(...) as above, then id() and contribute(...)
}
A library that is not an extension declares its contributor as a service. Its section comes after those of the extensions, by ascending order() (1000 by default), then by id().
The contract
| Method | Contract |
|---|---|
|
A stable lowercase id, such as |
|
The title printed after the id in the detailed report, the id by default; the position among the contributors declared as services. |
|
Writes the section. Called once per boot, on the booting thread, after every |
|
The level of this report, and the launch mode of this boot. The header already gives the reason of the mode: a section never repeats it. |
|
Whether an enabled bean has a bean type of this binary name, its class or one of its interfaces. It compares names and never creates the bean. |
|
The bean of this type with the default qualifier; empty when there is none, when several resolve, or when creating it fails. A |
|
The absolute URLs of the routes of a handler class, as the sections written so far declare them with |
|
The one line that stands for the section, printed at |
|
A row, a list: |
|
|
|
For HTTP servers and routing bricks: Vidocq prints each route with the base URI of its listener, whichever section declared it. |
|
Something wrong that does not stop the boot, under a code of your own, such as |
A minimal contributor
A translation library, which is not an extension, says how many languages its Translator bean knows, and warns when the bean is missing:
src/main/java/com/example/translate/report/TranslateReport.javapackage com.example.translate.report;
import com.example.translate.Translator;
import io.vidocq.runtime.spi.report.StartupReportContext;
import io.vidocq.runtime.spi.report.StartupReportContributor;
import io.vidocq.runtime.spi.report.StartupReportSection;
public final class TranslateReport implements StartupReportContributor {
@Override
public String id() {
return "translate";
}
@Override
public String title() {
return "Translation library";
}
@Override
public void contribute(StartupReportContext context, StartupReportSection section) {
Translator translator = context.lookup(Translator.class).orElse(null);
if (translator == null) {
section.anomaly("TRANSLATE-001", "No Translator bean is deployed, so /translate answers 404.",
"Add translate-cdi to the application.");
return;
}
section.summary(translator.languages().size() + " languages, default " + translator.defaultLanguage())
.row("default", translator.defaultLanguage())
.list("languages", translator.languages())
.secret("apiKey", translator.hasApiKey());
}
}
It is declared twice, because the JDK reads one declaration or the other depending on where the jar is: provides in module-info.java on the module path, where the JDK ignores META-INF/services for the classes of a named module, and a META-INF/services file on the class path.
src/main/java/module-info.javamodule com.example.translate {
requires io.vidocq.runtime.spi;
// ... the library's own requires and exports
provides io.vidocq.runtime.spi.report.StartupReportContributor
with com.example.translate.report.TranslateReport;
}
src/main/resources/META-INF/services/io.vidocq.runtime.spi.report.StartupReportContributorcom.example.translate.report.TranslateReport
The summary report gets its summary line:
extensions chappe-engine, rest-cassini, chappe-mount-config, chappe-bootstrap
translate 2 languages, default en
anomalies none
The detailed report gets its title and the time it took to write, then everything it wrote, aligned by Vidocq:
translate Translation library | 0 ms
2 languages, default en
default en
languages en, fr
apiKey not configured
anomalies none
Without the bean, the anomaly is logged the moment it is reported, and recalled at the end of the report:
[WARN ][2026-09-18 14:37:31.305][main ][i.v.r.c.r.StartupAnomalies#warn ] : [TRANSLATE-001] No Translator bean is deployed, so /translate answers 404. Add translate-cdi to the application.
...
translate Translation library | 0 ms
anomalies 1 (logged above)
TRANSLATE-001 No Translator bean is deployed, so /translate answers 404.
Rules
-
Read memory only. No I/O, no network, no blocking call, no bean you do not need:
contributeruns on the booting thread, beforeVidocq - Started in. Its time is printed in the detailed report, followed byslowbeyond 50 ms. -
Pass plain values. Never format, pad, truncate or log: Vidocq aligns the rows, replaces control characters with
?, cuts a value at 200 characters and a list at 50 items. -
Keep the summary line short and public. It is printed by default in production: counts and names, no absolute path, no configured value. A secret goes through
secret(key, configured), which never takes its value. -
Skip what will not be printed. Rows and lists only appear at
DETAILED; a contributor that has to compute them can testcontext.verbosity()first. -
A failure costs the section, never the boot. A
RuntimeExceptionor aLinkageErrorthrown by the contributor, or while it is loaded or created (aServiceConfigurationErrorincluded), drops its section withVIDOCQ-RPT-001and its stack trace; the anomalies it already reported stay. Any otherErroris not caught.
Layer twins
Vidocq.run loads the application’s modules a second time, in the Vauban application layer, so the service loader can find the same contributor class twice: once in that layer, once in the boot layer, the application layer’s first. Vidocq keeps one contributor per class name — the first found — before creating either, so each section appears once. For the same reason, an extension that is also declared as a contributor service is called once, as an extension.
What the rule means for a contributor:
-
Two contributor classes must not share an id: only the same class found twice is a twin, and a different class with the same id is skipped with
VIDOCQ-RPT-002. -
The two copies of a class are two different
Classobjects. To test whether a bean exists,hasBeanOfType(String)compares binary names, so it answers the same whichever copy asks.
Reading the startup report NEW
ExtensionContext.startupReport() gives an extension the startup report of its boot, read-only, as a StartupReportView of the package io.vidocq.runtime.spi.report. It is what the dev console shows; any development tool may read it the same way. Exporting the core’s report classes would have let any extension add an anomaly or a section to a report that is written: the view holds text, and nothing in it can be changed.
The report is written after every onStart, so the view is not there yet when onStart runs. startupReport() returns a supplier: keep it, and ask it when the report is needed, on a request for instance, from any thread.
private volatile Supplier<Optional<StartupReportView>> report = () -> Optional.empty();
@Override
public void onStart(ExtensionContext ctx) {
report = ctx.startupReport();
}
// later, on a request
report.get().ifPresent(view -> render(view.sections(), view.anomalies()));
| Method | Returns |
|---|---|
|
The launch mode of the boot, and why, as the header prints it: |
|
Every anomaly of the boot, the core’s and the contributors', in the order they were logged, as |
|
The sections in the order of the detailed report — the lines of its header ( |
|
The whole report as the log prints it at |
|
The contributors this boot called, the very instances, in the order of their sections. A reader may look for another interface they implement, as the console looks for |
The supplier is empty until the report is written, and again once Vidocq stops, which withdraws the view first. A view belongs to one boot and never changes: a dev reload publishes a view of its own. Its values are text written by the application and its libraries: a reader escapes them for where it shows them, and never renders them as markup. startupReport() is a default method that answers empty, so an ExtensionContext written by hand, such as a test fake, compiles unchanged.
Dev console panels NEW
A startup report contributor that the dev console also shows live implements DevConsolePanel. The panel SPI is a module of its own, so that only the extensions that have a panel depend on it:
Artefact |
|
Java module |
|
Source |
|
The module name falls under the io.vidocq.runtime.spi prefix, which keeps a module in the boot layer across dev reloads: the console and every panel see the same DevConsolePanel type after any number of reloads.
| Type | Role |
|---|---|
|
Extends |
|
Where |
|
What a number measures: |
|
A chart the page draws from successive samples: an id, a title, and its series in drawing order, checked when it is built. Repeated once per group that holds one of its keys. |
|
A value a chart plots and how: |
|
Something a panel offers to do from the page, in a |
Where a panel lives, how to write one, its rules, a worked example and how to test it: Writing a dev console panel.
Next steps
-
Concepts — formal vocabulary
-
Reference — public SPI table
-
Built-in extensions — concrete SPI consumption examples