Appearance
Route resolution
After an HTTP path has parsed into a route grammar (app, Space, explicit-authority), the gateway must pick which runtime serves that app. This page is the exact algorithm.
Two sources
_loadRoutes (system-gateway-http/src/index.ts:385-402) joins two sources:
typescript
private async _loadRoutes(): Promise<IWebappRoute[]> {
const routes = new Map<string, IWebappRoute>();
for (const route of this._loadHostRuntimeRecordRoutes()) {
routes.set(`${route.appName}�${route.runtimeId}�${route.assetMount ?? route.origin ?? ''}`, route);
}
for (const route of await this._loadRuntimeRegistryRoutes()) {
const key = `${route.appName}�${route.runtimeId}�${route.assetMount ?? route.origin ?? ''}`;
if (!routes.has(key)) {
routes.set(key, route);
}
}
return [...routes.values()].sort(/* by appName, then source priority, then runtimeId */);
}Source 1: Host runtime records
typescript
// _loadHostRuntimeRecordRoutes
const hostHome = this.context?.getService<string>(MATRIX_HOST_HOME_SERVICE)
?? process.env.MATRIX_HOST_HOME ?? '';
const runtimesDir = path.join(hostHome, 'runtimes');
for (const entry of fs.readdirSync(runtimesDir, { withFileTypes: true })) {
const recordPath = path.join(runtimesDir, entry.name, 'runtime.json');
// ... read each runtime.json, extract metadata.webapp
}These are on-disk files written by host-service when matrix up succeeds. They survive Host restarts. Only records with status: "running" are kept.
Source 2: runtime registry actor
typescript
// _loadRuntimeRegistryRoutes
const result = await RequestReply.execute(this.context, 'system.runtimes', 'runtimes.registered', ...);
// ... extract metadata.webapp from each runtime entryThis is a Matrix RR call to system.runtimes. It includes runtimes that don't have on-disk records — typically browser-tab runtimes whose webapp mounts are advertised via the registry but not via host-service.
Why both
Runtimes started by host-service have on-disk records. Runtimes started by the browser (a tab opening a webapp and registering an actor) do not. _loadRoutes covers both. Records win on key conflict because they're the authoritative source for host-service-managed lifecycle.
Webapp record extraction
Each candidate (whether from a host-runtime record or from the registry) goes through _routeFromWebappRecord to produce an IWebappRoute:
typescript
{
appName: string,
runtimeId: string,
packageName?: string,
assetMount?: string, // e.g. 'chat.http'
origin?: string, // e.g. 'http://127.0.0.1:5173' (dev mode)
routePrefix: string, // typically '/apps/<appName>/'
displayName?: string,
icon?: string,
navOrder?: number,
description?: string,
shells?: readonly string[],
source: 'host-runtime-record' | 'runtime-registry',
}A record without either assetMount or origin is dropped — there's no way to serve it. (_routeFromWebappRecord returns null.)
Selection priority
Within candidates for the same appName:
typescript
// _resolveRoute (system-gateway-http/src/index.ts:514-543)
const candidates = routes.filter((route) => route.appName === appName);
const route = candidates.find((c) => c.origin) // 1. origin (dev) wins
?? candidates.find((c) => c.assetMount) // 2. asset mount (prod)
?? candidates[0] // 3. anything
?? null;Origin-backed routes (a Vite dev server, typically) win over asset-mount routes because the developer running vite dev wants their hot-reloading copy served, not the built dist/browser/ copy.
In production, only assetMount routes exist; the priority is irrelevant.
Asset path extraction
assetPathForApp strips the /apps/<appName>/ prefix from the URL, returning the asset path inside the app:
/apps/chat/assets/index.js → appName: "chat", assetPath: "assets/index.js"
/apps/chat/ → appName: "chat", assetPath: ""
/apps/chat → appName: "chat", assetPath: ""The asset endpoint receives the full original pathname (so it can re-derive assetPath itself or use the raw URL); the gateway carries assetPath in the resolution mainly for diagnostics.
Resolution result
typescript
interface IHttpRouteResolution {
routeKind: 'matrix-asset-endpoint' | 'origin-backed-webapp';
pathname: string;
appName: string;
assetPath: string;
runtimeId: string;
source: 'host-runtime-record' | 'runtime-registry';
assetMount?: string;
origin?: string;
}This is what gateway.http.resolve returns and what the listener uses to route the actual request.
Cache
There is no caching today. Every request re-runs _loadRoutes, which re-reads files from disk and re-issues the system.runtimes RR.
This is acceptable for the current scale (a Host has tens of routes). For larger deployments, a TTL cache on _loadRoutes is a target-state optimization.
Failure modes
| Symptom | Likely cause |
|---|---|
404 for an appName that is registered | _loadHostRuntimeRecordRoutes excludes runtimes whose status isn't running. Check runtime.json status. |
Slow /apps/... first request | RR to system.runtimes is included in route resolution; if system.runtimes is on a slow-bootstrapping runtime, this hits. |
| Inconsistent app selection between requests | Two runtimes have registered the same appName. Resolution is deterministic per _loadRoutes sort order, but operators may see "wrong" runtime serving requests until the conflict is resolved. |
See also
Source:
projects/matrix-3/packages/system-gateway-http/src/index.ts:385-543.