Skip to content

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 entry

This 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

SymptomLikely cause
404 for an appName that is registered_loadHostRuntimeRecordRoutes excludes runtimes whose status isn't running. Check runtime.json status.
Slow /apps/... first requestRR to system.runtimes is included in route resolution; if system.runtimes is on a slow-bootstrapping runtime, this hits.
Inconsistent app selection between requestsTwo 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.