Addon System
Status: v1 in production since 2026-06-03 (slideshow as reference Addon). Design discussion and discarded alternatives:
planning/addon-system.md.
The Addon system allows third-party code to extend Brain and Face with Java
Beans (REST controllers, server tools, Vancetope applications) and Vue 3
editors. Distribution as a .vab bundle, activation
via the addons collection in MongoDB, controlled by vance-anus
CRUD. First first-party Addon: vance-addon-brain-slideshow.
1. Trust Model and Scope
| Aspect | v1 |
|---|---|
| Who installs | Admin via vance-anus CRUD + Container Build (for bundled) |
| Scope | System-wide; all Tenants see all active Addons |
| Trust Level | Code = trusted, no sandboxing |
| Per-Tenant Activation | Not in v1 |
| Code Signing | SHA-256 checksum optional at db.addons.checksum, verified by Entrypoint |
| Hot-Reload | No — Container Restart required |
2. Bundle Format .vab
An Addon is a ZIP file with the .vab extension:
<artifactId>-<version>.vab
├── META-INF/
│ ├── vance-addon.yaml Manifest: id, version (minimum)
│ └── spring/
│ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
├── brain/
│ └── lib/
│ └── *.jar Addon JAR + additional Java Deps
└── face/ optional — Module Federation Remote
├── remoteEntry.js
├── remoteEntry.ssr.js
└── assets/…
META-INF/ is mandatory. brain/ and face/ are both optional, but
usually at least one of them is present: a Brain-only Addon (server tool without UI)
has no face/, a pure UI Addon has no brain/.
2.1 Manifest
# META-INF/vance-addon.yaml
id: slideshow
version: 1.0.0-SNAPSHOT
id is the stable Addon name (lowercase, hyphen-separated). The
Brain and Face Entrypoints use it as a directory name under
/shared/addons/<id>/<version>/. version is the Maven version
(substituted via Resource Filtering with @project.version@ delimiters
— Spring Boot Parent sets the filtering delimiters to @…@).
In v1, no further manifest fields are required. Future
extensions: vanceApiVersion range, outbound: host list,
migrations: sub-format.
2.2 Spring AutoConfiguration.imports
One class per Addon, in the standard path:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
Content: one fully qualified class name per line, e.g.:
de.mhus.vance.addon.brain.slideshow.SlideshowAddon
The class itself:
@AutoConfiguration
@ComponentScan(basePackageClasses = SlideshowAddon.class)
public class SlideshowAddon {}
@ComponentScan(basePackageClasses=…) is sufficient — all @Service,
@Component, @RestController in the package will be detected.
3. Persistence: db.addons
The MongoDB collection addons is the runtime source of truth. Schema
(via Spring Data MongoDB):
{
_id: ObjectId,
name: string unique business key, matches id in manifest
path: string Source marker:
"bundled:<id>" → /default-addons/*.vab in image
"https://..." → URL to .vab, Entrypoint loads + caches
enabled: bool false = invisible in /face/addons, no unpack/mount
checksum: string? optional sha256:<hex>, verified during URL download
createdAt: Date audit
_class: string Spring Data type marker: "de.mhus.vance.shared.addon.AddonDocument"
}
Three operations write here:
| Who | How | Behavior |
|---|---|---|
| Brain Entrypoint | pymongo $setOnInsert per bundled .vab |
Insert only; existing rows (even enabled=false) remain |
vance-anus addon create/update/enable/disable/delete/set-checksum |
AddonService |
Full CRUD |
Manual mongosh |
direct | Emergency path, not recommended workflow |
Brain Service: de.mhus.vance.shared.addon.AddonService is the sole
entry, external modules access it (data sovereignty). REST Controller
in de.mhus.vance.brain.addon.AddonController only provides read operations.
4. Backend Loading (Brain)
4.1 vance-brain.jar with PropertiesLauncher
vance-brain/pom.xml sets <layout>ZIP</layout> in the
spring-boot-maven-plugin. Result: Main-Class in MANIFEST.MF is
org.springframework.boot.loader.launch.PropertiesLauncher instead of
JarLauncher. PropertiesLauncher reads -Dloader.path=… and appends the
directories/JARs listed there to the classpath before the app starts.
4.2 Container Entrypoint (deployment/docker/brain/docker-entrypoint.sh)
Three phases, each idempotent:
Phase 1 + 1b — Bundled Addons
Loop over /default-addons/*.vab:
unzip -p <vab> META-INF/vance-addon.yaml→ awk-parseid/version- If
/shared/addons/<id>/<ver>/.readyis missing → unpack via atomicunzip → rename → touch .ready pymongoupsert intodb.addonswith$setOnInsert: sets row only on insert (enabled=true,path="bundled:<id>"), existing rows withenabled=falseare NOT overwritten
Phase 1c — URL Addons (Cache)
Inline Python via pymongo:
- Iterate
db.addons.find({enabled: true}), skipbundled:paths - For each URL row: Cache file
/shared/addons-cache/<name>.vab- Exists + Checksum set + sha256 matches → cache hit
- Exists + Checksum not set → cache hit (unverified)
- Exists + Checksum mismatched → delete file, reload
- Does not exist → curl with
-fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 300
- After download: if checksum set, verify — on mismatch, delete file, skip row (loader.path will not contain the Addon)
- Read manifest from cache,
idmust match rowname - Unpack into
/shared/addons/<id>/<ver>/(atomic rename,.readymarker)
Phase 2 — loader.path
LOADER_PATH=$(find /shared/addons -path '*/brain/lib/*.jar' | tr '\n' ',' | sed 's/,$//')
exec java $JAVA_OPTS -Dloader.path="$LOADER_PATH" -jar /app/vance-brain.jar
Spring Boot scans for META-INF/spring/AutoConfiguration.imports at startup
on the entire classpath — thus finding the Addon configs.
@ComponentScan in the Addon config picks the Beans.
4.3 Addon JAR Dependency Rules
The Addon Maven module may directly depend on Brain:
| Module | Compile Deps | Scope |
|---|---|---|
vance-addon-brain-<id> |
vance-api, vance-shared, vance-brain |
provided |
provided because the mentioned modules are on the Brain classpath at runtime
— the Addon JAR does not need to bring them itself.
4.4 REST Endpoint Convention
Addon-specific REST endpoints must be under the prefix
/brain/{tenant}/addon/<addon-id>/... — where <addon-id> is the
id value from the Addon manifest (META-INF/vance-addon.yaml).
@RestController
public class WorkbookAppController {
@GetMapping("/brain/{tenant}/addon/workbook/scan")
public WorkbookView scan(...) { ... }
@PostMapping("/brain/{tenant}/addon/workbook/page")
public WorkbookPageView createPage(...) { ... }
}
Reasoning:
- Namespace Separation. Built-in Brain endpoints (
documents,chat,sessions,projects, …) live directly under/brain/{tenant}/. An Addon registering an endpoint/brain/{tenant}/documents/Xwould collide with the built-in one. Theaddon/<id>/prefix makes collisions impossible. - Discoverability. Anyone seeing an Addon route in logs / network tab
immediately knows: this belongs to Addon
<id>and not to the Core. In the debug path, this saves lookup effort. - Hot-Deploy Safety. During VAB hot-deploy / remove, all routes of an Addon are grouped under a single URL tree — cleaner cleanup, no stray top-level endpoints.
Frontend Convention (analogous). The REST helper in the Addon client
writes the addon/<id>/ prefix consistently:
return brainFetch<WorkbookView>('GET', `addon/workbook/scan?${qs}`);
brainFetch automatically adds the brain/{tenant}/ root — the
Addon client only writes the path from addon/<id>/....
5. Frontend Loading (Face)
5.1 Module Federation Host
vance-face is the host for @module-federation/vite. Remote list in v1
hardcoded in vance-face/vite.config.ts, with two mandatory details:
const addonRemotes: Record<string, any> = {
vance_addon_slideshow: {
name: 'vance_addon_slideshow',
entry: '/addons/slideshow/remoteEntry.js',
type: 'module', // ⚠ Mandatory: Vite emits remoteEntry as ESM
},
};
federation({
name: 'vance_face',
remotes: addonRemotes,
shared: {
vue: { singleton: true, requiredVersion: '^3.5.0' },
pinia: { singleton: true },
'vue-i18n': { singleton: true },
// ⚠ NO @vance/components, NO @vance/shared — see §5.3
},
});
type: 'module' is critical: without this hint, the
Federation runtime loads remoteEntry.js as a classic <script>, and the
ESM import statement at the beginning throws Cannot use import statement
outside a module. The Federation Record<string,string> TS surface
hides the object form — Record<string, any> bypasses this without
loss of functionality.
5.2 Remote Vite Config
Three build options are critical — all result from the
remote mount path /addons/<id>/, not from the doc root:
export default defineConfig({
base: '', // ⚠ Mandatory — see below
plugins: [
vue(),
federation({
name: 'vance_addon_slideshow',
filename: 'remoteEntry.js',
exposes: {
'./SlideshowApp': './src/SlideshowApp.vue',
},
shared: {
vue: { singleton: true, requiredVersion: '^3.5.0' },
// DO NOT share Workspace Packages — see §5.3
},
dts: false, // see note below
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: true, // ⚠ Mandatory — see below (Default true, do not override)
},
});
base: '' is mandatory. Vite default base: '/' generates in
preload-helper.js an assetsURL = function(dep) { return "/"+dep }
and bundles chunk preload paths root-absolutely (/assets/X.js). The
host, however, serves the remote files under /addons/<id>/ — the
preload Hrefs thus end up as /assets/X.js instead of
/addons/<id>/assets/X.js and 404. For Addons with eager cross-
chunk imports (e.g., a Codec eager + the View lazy), async
component loading fails completely. Empty base → assetsURL =
function(dep, importerUrl) { return new URL(dep, importerUrl).href }
and the resolution new URL('./X.js', chunkUrl) correctly lands
under /addons/<id>/assets/X.js. Symptom of incorrect
setting: register() fires, but CalendarView 404s.
cssCodeSplit: true (Vite default) is mandatory. Federation
injects CSS <link rel=stylesheet> tags per expose via the
virtualExposes.js cssAssetMap. With cssCodeSplit: false, Vite bundles
everything into a global style.css without expose assignment, the
map is {}, and the host never sees the Addon styles — the editor
renders as unstyled DOM. Symptom: component renders,
but has no styles.
dts: false disables the DTS plugin of @module-federation/vite —
it internally tries to run vue-tsc with its own tsconfig and
fails on .vue files. The actual type generation runs via
the vue-tsc -b step in the build script anyway.
5.3 Why Workspace Packages are NOT shared
@module-federation/vite emits a loadShare__<pkg> module for each entry in the shared: block.
For npm packages (vue, pinia), this is a lazy lookup. For workspace packages, the plugin builds
top-level await code with await import("./<impl-chunk>.js"). This impl-chunk, in turn, statically
imports from the loadShare module → ESM cycle with TLA deadlock, no console error, host boot hangs
indefinitely (silent failure).
Workaround: @vance/components and @vance/shared are not
shared. Each consumer (Host + each Addon) bundles its own
copy. Trade-off:
@vance/components: ~12–20 KB duplication per Addon — acceptable@vance/shared: actually has state (configurePlatformbindings) → cross-bundle state is instead shared viaglobalThis.__VANCE_PLATFORM__(see §5.4)
If a state singleton in @vance/shared is added in the future that cannot
be distributed via globalThis, the sharing problem must be
fundamentally solved — e.g., @vance/shared as a published
npm package instead of a workspace package.
5.4 globalThis State for @vance/shared
Because @vance/shared is not shared via Federation, each copy
has its own module-scoped bindings variable. When the Host
calls configurePlatform({ storage, rest }), it only configures its
own copy — the Addon copy remains unconfigured and throws platform not configured
on the first brainFetch call.
Solution: @vance/shared/src/platform/index.ts externalizes the bindings to
globalThis.__VANCE_PLATFORM__. Each copy reads and writes
there. Thus:
- Host copy calls
configurePlatformon boot → writes to globalThis - Addon copy calls
getRestConfig()→ reads from globalThis → configured
Advantages:
- No coupling Host ↔ Addon (no Addon code references vance-face)
- Works in Web and React Native (
globalThisis in both) - Addon author writes
import { brainFetch } from '@vance/shared'unchanged
5.5 Container Entrypoint (deployment/docker/face/docker-entrypoint.sh)
Analogous to Brain, with nginx symlinks as output:
Phase 1 — Bundled unpack (without Symlink)
Like Brain Phase 1: /default-addons/*.vab → /shared/addons/<id>/<ver>/,
.ready marker. No symlink in this phase — it is set in 1c,
controlled by db.addons.
Pre-Phase — Symlink Wipe
find $NGINX_ADDONS_ROOT -mindepth 1 -maxdepth 1 -exec rm -rf {} + —
all old symlinks removed. This ensures a restart always reflects the
current /face/addons response, even if an Addon was disabled in the meantime.
Phase 1c — Query Brain + For each row, either resolve or fetch
for i in $(seq 1 60); do
addons_json=$(curl -fsS --max-time 5 "$BRAIN_INTERNAL_URL/face/addons") && break
sleep 5
done
60 attempts × 5s = max 5 min wait for Brain. Default
BRAIN_INTERNAL_URL=http://brain:9990 (docker-compose service DNS;
in K8s the internal service DNS). If the cap is exceeded, nginx starts
with an empty Addon list — static page runs, /addons/* 404.
For each row in the JSON:
bundled:<id>→latest_version_for "$name"scan in/shared/addons/<name>/*/, symlink$NGINX_ADDONS_ROOT/$id → /shared/addons/<id>/<ver>/facehttp(s)://...→ Cache + Verify (see Brain Phase 1c, identical logic in shell form with curl + sha256sum), then unpack + symlink
Phase 2 — nginx
exec nginx -g 'daemon off;'
nginx.conf serves /usr/share/nginx/html/, the symlinks under
/addons/<id>/ fall under location / with try_files.
5.6 /face/addons REST Endpoint
vance-brain exposes GET /face/addons (marked as auth-free by BrainAccessFilter,
path prefix /face/ bypasses Tenant/JWT requirement).
It only returns enabled rows as AddonDto (name, path, checksum). v1 is
hardcoded as the only /face/ endpoint; Convention: all future
face-bootstrap-discovery routes live under it.
5.7 Dev Mode: Vite Middleware
vance-face’s vite.config.ts contains the plugin vanceAddonDevServe()
which intercepts two paths in the Dev server:
-
/addons/<id>/*— bridges torepos/vance/server/vance-addon-brain-<id>/client/dist/<path>. This allows the Dev server to serve the Federation remote files that are delivered in the Prod image by the face Entrypoint via nginx symlink. -
/face/addons— stand-in for the static JSON that the face Entrypoint writes out as an nginx static asset in the Prod image. In Dev mode, the plugin lists allvance-addon-brain-*/client/dist/that have aremoteEntry.jsas[{name: <id>, path: "bundled:<id>"}, …]. Without this endpoint,loadAddonRegistrations()gets 404 and fails silently — Federation imports in the code still run (static imports recognizable by the bundler), but runtime kind contributions viaregister()are never called.
Dev workflow:
pnpm --filter @vance-addon/<id> buildonce →client/dist/existspnpm --filter @vance/vance-face dev→ Dev server, HMR for vance-face- Change Slideshow code →
pnpm --filter @vance-addon/<id> buildagain, Hard-Reload in browser
The middleware is pattern-based: /addons/<id>/* automatically maps
to vance-addon-brain-<id> — new Addons do not need config
extension.
6. Admin Workflow: vance-anus
Spring Shell commands for the addons collection (all @RequiresAuth):
| Command | Does |
|---|---|
addon list |
All rows (including disabled), columns: NAME, PATH, ENABLED, CHECKSUM, CREATED |
addon show --name <n> |
Single row |
addon create --name <n> --path <p> [--checksum sha256:…] |
Insert; fails if name exists |
addon update --name <n> --path <p> |
Change path (name immutable) |
addon set-checksum --name <n> --checksum <c> |
Set checksum or delete with empty string |
addon enable --name <n> / disable --name <n> |
Flip flag |
addon delete --name <n> |
Hard-delete of the row (cache file remains for cleanup cronjob) |
Implementation: vance-anus/src/main/java/de/mhus/vance/anus/shell/AddonCommands.java.
The commands call AddonService (in vance-shared) — same service
as the Brain controller, thus same data sovereignty, same constraints.
7. Build and Deploy Pipeline
7.1 Produce .vab
In the Addon Maven pom, three plugins in the package phase: TS-Gen (for
DTOs), exec for pnpm (Federation remote build), maven-assembly-plugin
for the ZIP, maven-antrun-plugin for .zip → .vab rename. Complete
pom template see readme/addon-development.md.
7.2 Container Image Build
deployment/docker/{brain,face}/build-image.sh stages all
vance/vance-addon-brain-*/target/*.vab to deployment/docker/{brain,face}/addons/
before docker build:
ADDON_STAGE="deployment/docker/brain/addons"
rm -rf "${ADDON_STAGE}" && mkdir -p "${ADDON_STAGE}"
for vab in vance/vance-addon-brain-*/target/*.vab; do
cp "${vab}" "${ADDON_STAGE}/"
done
Glob-based: every new first-party Addon is automatically included.
7.3 Image Layout
| Image | Contents |
|---|---|
vance-brain |
Slim vance-brain.jar (PropertiesLauncher), /default-addons/*.vab, pymongo+unzip+curl for the Entrypoint |
vance-face |
nginx + vance-face/dist/, /default-addons/*.vab, unzip+curl+jq for the Entrypoint |
vance-anus |
Spring Shell REPL with AddonCommands (same vance-shared as Brain) |
7a. Manuals and Cascade Resources in the Addon JAR
Each Addon ships its own Manuals (and other Cascade Resources like Recipes, Setting Forms, Skills) in its own module under:
<addon>/src/main/resources/vance-defaults/_vance/<topic>/<file>
In the built Addon JAR, they are under vance-defaults/_vance/..., in
the .vab under brain/lib/<addon>.jar, in the container under
/shared/addons/<id>/<ver>/brain/lib/<addon>.jar and thus via
-Dloader.path on the Brain classpath.
DocumentService.lookupCascade() (vance-shared) finds them
automatically. Two Spring ResourcePatternResolver modes are used,
both work across JAR boundaries:
classpath:vance-defaults/<path>— single resource. Returns the first hit. PropertiesLauncher orders Addon JARs before the App JAR, so the Addon wins on path duplication. For clean migrations (resource out ofvance-brain, into Addon) there is no duplication — the only hit comes from the Addon JAR.classpath*:vance-defaults/<prefix>*— listing all hits across all classpath entries. Addon Manuals appear in the listing automatically, without configuration.
Consequence for migration: if a Java module moves to an Addon (e.g., Calendar Stage 2.x), its resources move with it. No registration in a central registry, no boot hook in Brain, no seeding in MongoDB. Just `git mv vance-brain/…/resource.md