xPulse
πŸ‡¬πŸ‡§ EN

@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:


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

en/service-loader.md 2026-04-10