Pages
Contents
@xpulse/app β Service Loader
Status: ACCEPTED Β· March 2026 Replaces: hardcoded
bootstrap/services.js
Problem
bootstrap/services.js is a hardcoded list of tryInit() calls in a fixed sequence.
Every new @xpulse/* package requires a manual edit in @xpulse/app.
This violates the auto-discovery principle the rest of the framework follows.
Solution: Auto-Discovery Service Loader
The service loader reads xpulse.json from every installed @xpulse/* package,
builds a dependency graph from the service section, sorts it topologically,
and calls each package's init() in the correct order.
No manual registration. Installing a package is enough.
Service Metadata in `xpulse.json`
Each @xpulse/* package declares itself in its own xpulse.json:
| { |
| "name": "xpulse-router", |
| "type": "component", |
| "service": { |
| "xpulse-router": { |
| "load": true, |
| "load-after": ["xpulse-http"] |
| } |
| } |
| } |
Rules:
- The key inside
serviceis the full package name (e.g.xpulse-router). load: trueβ this package participates in the service loader.load-afterβ list of package names that must be initialised first.- Packages without a
servicesection are ignored by the loader. @xpulse/eventand@xpulse/confighave noservicesection β they are always available before the loader runs.
Dependency Graph (reference)
| xpulse-logger |
| xpulse-http |
| βββ (none) |
| xpulse-router |
| βββ xpulse-http |
| xpulse-template |
| βββ (none) |
| xpulse-theme |
| βββ xpulse-router, xpulse-config |
| xpulse-controller |
| βββ xpulse-router, xpulse-template |
| xpulse-session |
| βββ xpulse-router, xpulse-crypto |
| xpulse-debug |
| βββ not part of the service loader β see below |
| xpulse-doc |
| βββ xpulse-router |
App-Level Overrides
The application's own xpulse.json can override any service entry.
The loader deep-merges the app config on top of the collected package configs.
Disable a service:
| { |
| "service": { |
| "xpulse-theme": { "load": false } |
| } |
| } |
Register an app-local service (from src/services/):
| { |
| "service": { |
| "my-auth": { "load": true, "load-after": ["xpulse-router", "xpulse-session"] } |
| } |
| } |
App-local services are discovered from src/services/*/xpulse.json
and merged before the app-level override is applied.
Merge Order
| 1. node_modules/@xpulse/*/xpulse.json β package defaults |
| 2. src/services/*/xpulse.json β app-local services |
| 3. xpulse.json { "service": { β¦ } } β app overrides (last wins) |
Because each package only declares its own name as the key, step 1 never has conflicts. Steps 2 and 3 can override anything from step 1.
Loader Algorithm
| 1. readdir(node_modules/@xpulse/*) |
| 2. For each: read xpulse.json β extract service section |
| 3. readdir(src/services/*) β same |
| 4. Deep-merge app xpulse.json service section on top |
| 5. Filter: load !== false |
| 6. Topological sort by load-after[] |
| 7. For each (in order): |
| - import('@xpulse/<name>') or import('src/services/<name>') |
| - call mod.default.init() if exists |
| - emit app:service:ready / app:service:error / app:service:skipped |
A circular dependency throws an error with a clear message naming the cycle.
A missing dependency (listed in load-after but load: false or not installed)
emits app:service:skipped and continues β resilient by default.
CLI Behaviour
When running via npx xpulse <command>, app.init() is called without
app.start(). The full service stack boots β identical to a normal app start β
but the HTTP server does not begin listening on a port.
This is the existing behaviour and does not change with the new loader.
Events
| Event | Payload | When |
|---|---|---|
app:service:ready |
{ service } |
Service initialised successfully |
app:service:skipped |
{ service, reason } |
Not installed or load: false |
app:service:error |
{ service, error } |
Init threw, loader continues |
@xpulse/debug β Special Position
@xpulse/debug is not part of the service loader.
It has its own dedicated bootstrap step that runs before the loader,
so that every subsequent service is already debuggable when it inits.
Controlled via xpulse.json as always:
| { "debug": { "enabled": true } } |
The service section of @xpulse/debug/xpulse.json has no load entry.
The loader ignores it completely.
Bootstrap sequence in app.init():
| 1. config.load() β always |
| 2. debug.bootstrapDebug() β only when debug.enabled β before loader |
| 3. service loader β all other @xpulse/* packages |
Implementation Notes
bootstrap/services.jsis replaced entirely by the new loader.- The loader lives in
bootstrap/loader.jsβservices.jsis deleted. - No new external dependency is needed β pure Node.js
readdir+ dynamicimport.