Skip to content

Shell and remote menus

Mateu allows you to build a shell application that composes UI modules from multiple services.

The shell defines:

  • authentication
  • branding
  • navigation
  • shared UI elements
@UI("")
@Title("Console")
public class ShellHome {
}

Remote menus allow the shell to include UI modules from other services.

@Menu
RemoteMenu users = new RemoteMenu("/_users")
.withServerSideType("...");

Each remote menu points to a UI exposed by another service.

The remote can be any Mateu app, including a product’s console built with Mateu — see Using external Mateu UIs for CORS, tokens, deep links and wire versions across the two services.

The shell itself calls each remote (server to server) to read its descriptor. Where that call goes is decided by configuration only, never by the request:

  • an absolute baseUrl (https://forms.acme.com) is asked as written;
  • a relative baseUrl (/_users — another path of the same deployment) is resolved against this server’s own base url: the mateu.self-base-url setting (an origin, e.g. https://shell.acme.com), else what the adapter knows of its local socket (Spring MVC and WebFlux over plain http answer http://localhost:<port>). When neither is known the remote is treated as unreachable — set the property.
PropertyEnv varDefault
mateu.self-base-urlMATEU_SELF_BASE_URLunset (adapter’s local socket)
mateu.remote.allowed-hostsMATEU_REMOTE_ALLOWED_HOSTSunset (any http/https host)
mateu.remote.timeout-secondsMATEU_REMOTE_TIMEOUT_SECONDS30

mateu.remote.allowed-hosts is an optional comma-separated allow-list (forms.acme.com, orders.internal:8080): when set, a call to any other host is refused. An entry without a port allows every port of that host; with one, it must match the port written in the url. A route appended to the remote’s url can never leave <baseUrl>/mateu/v3/sync/ (a .. that would is refused).

To build the navigation, the shell asks each remote for its descriptor (title, menu, home wiring). That is an HTTP round trip landing on the remote’s home route, and it happens every time the shell resolves a route — the first of the two requests a user sees on every page change.

The descriptor is app-shaped, not request-shaped: it only changes when the remote is redeployed. The shell therefore caches it briefly, keyed by the remote’s base URL, route and the caller’s authorization token (a remote is free to build a menu per user, so the token is part of the key, stored as a SHA-256 digest).

PropertyEnv varDefault
mateu.remote-menu.descriptor-ttl-msMATEU_REMOTE_MENU_DESCRIPTOR_TTL_MS30000

The TTL bounds how long a redeployed remote’s new menu takes to appear in the shell, which is why the default is 30 s and not longer. Set it to 0 to disable the cache and ask on every navigation. The system property wins over the environment variable.

The browser asks every remote for its menu and puts each answer where its section is. A few rules keep the shell steady while that happens:

  • One remote down is one section down. The remotes are asked together and each answer is merged as it arrives. A remote that does not answer (within 20 s) leaves its section in the menu, dimmed, with a tooltip saying it is not available; it is asked again in the background (after 10 s, 30 s and 60 s) and whenever the user clicks the section. The rest of the menu, the page on screen and the app-wide connection banner are not affected.
  • The shell’s label wins. When the shell declares a section’s label — @Label on the field or withLabel(...) — that label stays, even after the remote answers with its own (when the remote answers with a single top-level entry, the usual “a group named after the service”). Without a declared label, the field name is shown until the remote answers, and then the remote’s label replaces it, so a remote can still translate its own name.
  • A declared label groups the remote’s entries. When the remote answers with several top-level entries and the shell declared the section’s label, they go into a group (submenu) with that label, its icon and its path, in every variant — HAMBURGER_SECTIONS already made it a section. Pasted flat, each of the remote’s pages (its own “Home” too) sat next to the shell’s and the label was lost. Without a declared label they are still pasted in place: there is no name to give the group. Only the menu’s shape changes: each entry keeps its route and its remote, so clicks, deep links and reloads reach the same screens as before.
  • Route collisions are reported. A remote’s routes are relative to its own mount and are used as they are, so the shell’s /inicio and a remote’s /inicio are the same address and only one of them can be reached by it. When the shell and a remote, or two remotes, claim the same route, the browser console says so once, naming the route and both sources (mateu: route collision — "/inicio" is claimed by the shell and by RemoteMenu "Library" (…)). Nothing is overridden on purpose: give one of them a different route.
  • The section is known before the remote answers. Each remote section travels with the route prefix its screens live under: the remote’s own path — its field name, /forms for RemoteMenu forms, wherever the shell groups it. So on a cold load the active section and the first breadcrumb (“Admin › Forms”) are there on the first paint, and the rest of the trail follows when the remote answers. A deep link also says which remote it was mounted from, so a section whose field is named after something else (workflowAdmin serving /workflow/...) is found too.
  • Hidden sections still place a page. A @Hidden remote section is not drawn, but it stays in the navigation tree: a page under it gets its breadcrumbs. Its menu is fetched only while the user is in it.
  • The variant is the app’s. A shell with remote sections used to be forced to MENU_ON_TOP in the browser. Now the declared variant is respected; AUTO (and a fluent AppShell with no variant) picks MENU_ON_TOP for such a shell, so an app that declares nothing looks the same.

Both renderers apply the same rules: the web renderers through navTree.ts in libs/mateu, and Redwood through a port of it (apps/redwood/poc/navTree.mjs, its bridge cannot import TypeScript).

A deep link that no local entry claims is resolved on the server. The shell picks the remote by longest prefix from what it already knows — the cached menus of its remotes (see below) and each remote’s mount path — and asks only that one. Only when that tells nothing, or the chosen remote turns the route down, are the rest asked, all at once, and the longest claim wins. A remote that is down claims nothing; it no longer fails the shell’s request.

  • shell → composition
  • service → UI + logic
  • no frontend integration layer
  • independent deployment
  • clear boundaries
  • simpler architecture

The shell is a container.

Each service plugs its UI into it.

See it live in the Explorer: RemoteMenu federation & deep links