Routing
Oidis provides two routing mechanisms: @Resolver for mapping URLs to page/panel controllers, and @Route for defining REST API endpoints.
@Resolver — URL to Controller
The @Resolver decorator maps a URL path to a controller class. When a user navigates to that URL, the framework instantiates the controller and runs its lifecycle.
import { Resolver, ResolverType, ResolverScope } from "@io-oidis-commons/.../Primitives/Decorators.js";
@Resolver("/main")
export class MainPageController extends BasePageController<MainPage> {
// Handles requests to /main
}
@Resolver("/settings")
export class SettingsPageController extends BasePageController<SettingsPage> {
// Handles requests to /settings
}
Resolvers are auto-detected — you don't need to register them in a routing table or ForceCompile. The decorator registers the mapping in globalThis.oidisResolvers at class load time.
Resolver Options
@Resolver("/admin", {
type: ResolverType.BACKEND_ONLY,
scope: ResolverScope.PROD,
priority: 10
})
| Option | Values | Description |
|---|---|---|
type | UNIVERSAL, BACKEND_ONLY, FRONTEND_ONLY | Which side handles the request |
scope | ALL_PROFILES, PROD, DEV | When the resolver is active |
priority | number | Resolution order (higher = checked first) |
ResolverType
UNIVERSAL(default) — Works on both frontend and backendBACKEND_ONLY— Only available on the server (e.g., API controllers)FRONTEND_ONLY— Only available in the browser
ResolverScope
ALL_PROFILES(default) — Active in all environmentsPROD— Only in production buildsDEV— Only in development mode (class exists but is never matched in production routing). Useful for test pages, debug panels, and runtime test viewers.
Resolver Authorization
Resolvers support a two-level access control system:
- Page level — Override
accessValidator()in your PageController to restrict the entire page - Tab level — Override
AccessValidator()(capital A) in your PanelController to restrict individual tabs
When access is denied, both levels call reloadToNotAuthorized(), which by default redirects to /ServerError/Http/NotAuthorized.
For code examples and details on both access control levels, see Page-Panel-Controller — Access Control. For the @Resolver decorator API reference, see Decorators — Resolver.
@Route — REST API Endpoints
The @Route decorator defines HTTP REST endpoints on backend methods. It supports standard HTTP methods, typed query parameters, and path parameters.
import { Route } from "@io-oidis-localhost/.../Primitives/Decorators.js";
export class ProductAPI extends BaseConnector {
@Route("GET /api/products?offset=int(0)&limit=int(50)")
public async ListProducts($offset : number, $limit : number) : Promise<IModelListResult> {
// Handles GET /api/products?offset=10&limit=25
}
@Route("GET /api/products/{id}")
public async GetProduct($id : string) : Promise<IProduct> {
// Handles GET /api/products/64a7b2c3d4e5f6a7b8c9d0e1
}
@Route("POST /api/products")
public async CreateProduct($data : ProductRecord) : Promise<void> {
// Handles POST /api/products (body = JSON)
}
@Route("DELETE /api/products/{id}")
public async DeleteProduct($id : string) : Promise<void> {
// Handles DELETE /api/products/64a7b2c3d4e5f6a7b8c9d0e1
}
}
Query Parameter Types
Typed query parameters with optional defaults:
| Syntax | Type | Example |
|---|---|---|
int() | Integer, no default | ?page=int() |
int(0) | Integer, default 0 | ?offset=int(0) |
float(1.1) | Float, default 1.1 | ?rate=float(1.1) |
boolean(false) | Boolean, default false | ?active=boolean(false) |
string(test) | String, default "test" | ?status=string(active) |
@Route("GET /api/search?query=string()&page=int(1)&size=int(20)&active=boolean(true)")
public async Search($query : string, $page : number, $size : number, $active : boolean) {
// All parameters are typed and have defaults
}
Path Parameters
Use {paramName} for path segments:
@Route("GET /api/groups/{groupId}/members/{memberId}")
public async GetGroupMember($groupId : string, $memberId : string) {
// Handles GET /api/groups/abc123/members/def456
}
Hash Parameters
Use #{paramName} for hash-based routing:
@Route("GET /view#{section}")
public async ViewSection($section : string) {
// Handles GET /view#settings
}
Multiple Routes on One Method
A single method can have multiple @Route decorators for alternative endpoints:
@Route("GET /api/v1/users")
@Route("GET /api/v2/users")
public async ListUsers() : Promise<IModelListResult> {
// Accessible via both /api/v1/users and /api/v2/users
}
Route Registration
Routes register themselves in globalThis.oidisAPIRoutes via a generated methodName_route_Route() function. Like @Resolver, no manual registration is needed.
@Extern + @Route — Dual Access
Methods can use both decorators simultaneously:
@Extern()
@Route("GET /api/orders?offset=int(0)&limit=int(50)")
public async ListOrders($offset : number, $limit : number) : Promise<IModelListResult> {
// Business logic — runs only after the AuthManager authorized the caller
}
This makes the method accessible via:
- LCP — Frontend connectors using
asyncInvoke("ListOrders", ...) - REST — External HTTP clients via
GET /api/orders?offset=0&limit=50
Both paths go through the same AuthManager permission system. The developer decides per-method which access patterns to support.
Decorator Comparison
| Decorator | Purpose | Registration | Auth Mechanism |
|---|---|---|---|
@Resolver | URL → controller mapping | globalThis.oidisResolvers | accessValidator() / AccessValidator() protected methods |
@Route | REST API endpoint | globalThis.oidisAPIRoutes | AuthManager (same as @Extern) |
@Extern | LCP RPC method | MethodName_Extern wrapper | AuthManager (IsAuthorizedFor before the method runs) |
Client-Side Routing: Hash vs History API
Oidis SPAs run in one of two client routing modes. The mode is a single build-config flag and changes how URLs look, how the browser reports the current route, and how deep links reach the server.
Selecting the mode
target.historyRouting in package.conf.jsonp (typed by IProjectTarget, declared in package.conf.schema.jsonp) selects the mode:
- absent /
false— hash mode (default). true— History API mode.
At page render FrontEndLoader injects the resolved value as globalThis.oidisHistoryRouting, and every client read goes through HttpRequestParser.HistoryApiEnabled() (which also accepts an explicit boolean override, e.g. in tests).
| Hash mode (default) | History API mode | |
|---|---|---|
| URL shape | https://app/#/settings | https://app/settings |
| Current route source | window.location.hash | window.location.pathname (minus app root) |
| What the server receives | always / (fragment is never sent) | the full path |
| Server cooperation | none needed | SPA-shell fallback required (provided automatically) |
How the current route is read
HttpRequestParser.getScriptPath() returns the SPA route:
- history —
window.location.pathnamewith the applicationrelativeRootprefix removed and the query stripped. - hash — the substring after
#.
Event wiring (EventsManager)
- hash mode — binds
window.onhashchange = hashHandler; one hash change fires exactly oneON_HTTP_REQUESTresolve. - history mode — binds three listeners:
popstate → hashHandler— back/forward andpushState-driven resolves.hashchange → legacyHashHandler— backward compatibility: a legacy#/routelink followed inside a running history-mode SPA is rewritten withreplaceStateto the clean/routeand resolved. Plain#anchorin-page fragments are left untouched.click → anchorInterceptor— intercepts same-origin<a href="…#/route">clicks and routes them throughReloadTo. Clean-path links and bare#anchor/#placeholders pass through to the browser.
Server-side handling (history mode)
A hard load or refresh of /route must return the SPA shell. HttpServer.resolveUrl handles this: when the requested path is a front-end route it serves / (the shell). isHistoryFrontEndRoute treats a path as front-end only when its last segment has no file extension and no backend resolver claims it — so backend routes keep priority and run normally. Hash mode needs none of this because the fragment never reaches the server.
Deep links and the backend → front-end handshake
A backend resolver that redirects into a front-end route works in history mode too, by design. Given a backend 302 → /#/route:
- The browser loads the SPA shell; the address bar carries the
#/routefragment. - On bootstrap the
HttpResolverconstructor detects the#/routefragment under history mode andreplaceStates it to the clean/routebefore the first resolve. - The initial resolve reads
pathname=/routeand runs the front-end resolver. BecausereplaceStateis pure client-side URL manipulation, the backend is not called again — the 302 is a one-way hop into the SPA.
So a /foo that exists on both sides resolves on the backend first (backend priority), 302s to /#/foo, and the SPA rewrites the address to /foo and renders the front-end view without re-invoking the backend. The user sees a clean /foo URL and a single, consistent redirect.
Same route on back-end and front-end (intentional hash redirects)
The handshake above is deliberately used to host the same path on both tiers. A path can carry both a backend @Route (e.g. POST /print, GET /logs, the MCP /mcp/authorize API) and a front-end @Resolver (the print render view, the log viewer, the MCP consent page). Backend resolution takes priority — HttpServer.isHistoryFrontEndRoute treats any path a backend resolver claims as not a front-end route, so it is never served the SPA shell. To still reach the front-end view, the server redirects or links to the hash form /#/route:
Http404NotFoundResolver/Http403ForbiddenResolver/GraphiQLLoaderemit a raw302 Location: <host>/#/ServerError/....AuthenticationResolverbounces the headless SSO branch to/#/login.McpOAuthConnectorbounces a cold browser to/#/mcp/authorize/...;PrintManagerdrives headless Chrome to/#/print/....
This is intentional and correct: the /#/route form is invisible to the user (the bootstrap handshake rewrites it to a clean /route and renders the front-end view) yet it bypasses the backend claim on that path, so both tiers coexist under one URL. Do not "clean up" these hashes — under history mode a clean /route would resolve to the backend tier instead.
Server-side URL generation (emails, exports, notifications)
Links composed on the server — activation/reset/invitation emails, crash-report links, print render URLs, console hints — cannot use HttpRequestParser.HistoryApiEnabled(): that reads the browser-only globalThis.oidisHistoryRouting flag, which no server code sets, so it always reports hash mode on the server. Read the mode from config instead: Loader.getInstance().getAppConfiguration().target?.historyRouting === true (the same read HttpServer and FrontEndLoader already use).
Compose the shape with HttpRequestParser.ToFrontEndRoute($route): it returns a clean $route under history routing and /# + $route otherwise. It resolves the mode itself — the browser-injected flag via HistoryApiEnabled(), falling back to target.historyRouting from the project config (the same getEnvironmentArgs().getProjectConfig() read that works on both tiers, see Frontend Configuration) — so one call is correct on the front-end and the server, with no mode argument to thread through. A front-end-only route (activation, reset, /report, /AdminInit) becomes a clean path under history; a route that also has a backend @Route (the same-route pattern above — /logs, /print, /mcp/authorize) keeps the hash so the fragment handshake reaches the front-end tier — pass such a route already in /#/… form to keep it verbatim. A hash-form link still works in both modes (the bootstrap handshake resolves it), so emitting the clean shape under history is a cosmetic/consistency improvement, not a correctness fix.
Programmatic navigation
All helpers live on getHttpManager() and work in either mode:
CreateLink($link)— builds an app-relative link. Under history mode it strips a leading#/sopushStatereceives a clean path (hash mode keeps the#/). Always build SPA links through it.NavigateTo($link)— awaitable SPA navigation (history:pushState+ explicit resolve; hash: setswindow.location.hash). Same-origin relative links only. Alwaysawaitit.ReloadTo($link)— SPA navigation for app-relative links (history:pushState+ resolve; hash: sets the hash). It falls back to a fullwindow.location.hrefload only for absolute / cross-origin targets. It is not a full-page reload for in-app routes.
Because a clean-path link is indistinguishable from a real navigation, every in-app link must go through CreateLink / NavigateTo / ReloadTo so the mode-appropriate handling (and the #/ strip under history) applies.
Timing difference (advanced)
The two modes schedule the post-navigation resolve differently: hash mode relies on the browser's native hashchange / popstate event (dispatched as a fresh macrotask after the current one drains), whereas history mode fires the resolve itself via a setTimeout(0) after pushState. Code that assumes pending timers or transient DOM state survive a navigation identically in both modes can behave differently. Prefer explicit, mode-agnostic handoffs — e.g. carry a status message to the target page and show it there — over relying on the source page persisting across a redirect.
Status messages across a redirect (carry-over-on-wipe)
A toast raised via BasePageLayoutController.ShowMessage right before a redirect (a ReloadTo to a different page, an access-denied bounce, or a bootstrap deep-link) would otherwise be lost: the toast is painted into the current page's toast container, and the redirect target's first render wipes the content holder (ElementManager.setInnerHtml(PageContentHolder, "") in AsyncBaseHttpController) before the toast could be seen.
The framework handles this transparently:
ShowMessageshows the toast live and records it as the most recent message (pendingMessage).- A fresh render sets
contentWiped = true(AsyncBaseHttpController— true only when the holder was actually cleared, i.e. the target page's first render; a cached re-render leaves it false). - The target controller's
onSuccessseescontentWipedand re-shows the pending message on the freshly rendered page via that page's ownShowMessage, guarded by areplayingflag so it does not re-record itself.
Because the re-show goes through the target page's own ShowMessage, per-page customizations are preserved — a page that overrides getNewToast (appearance) or supplies its own toast container in an innerHtml() override (placement) keeps them. This is why the toast is treated as a global message routed to whichever page ends up rendered, rather than pinned to a specific URL. It covers every redirect timing (synchronous resolve, asynchronous access-check bounce, bootstrap) in both routing modes, needs no per-project code, and does not touch independent toast systems such as SystemStateNotification.
For detailed navigation API, usage patterns, and examples, see Panels and Controllers — Programmatic Navigation.
Summary
@Resolver("/path")— Maps browser URLs to Page/Panel controllers. Auto-detected, supports type/scope/priority.@Route("METHOD /path")— Defines REST endpoints with typed parameters. Independent of@Extern.- Both are auto-registered — No routing tables, no
ForceCompileentries needed. - Authorization —
@ResolverusesaccessValidator()methods;@Routeand@ExternuseAuthManager. - Dual access — Combine
@Externand@Routeon the same method for both LCP and REST access. - Routing modes —
target.historyRoutingtoggles hash mode (/#/route, default) vs History API mode (clean/route); both resolve through the same@Resolvertable. - Navigation —
NavigateTo()(awaitable) andReloadTo()both do in-app SPA routing in either mode;ReloadTo()only becomes a full-page load for absolute / cross-origin links.
@Route Decorator — Advanced
Syntax
@Route('<METHOD> /path?key=type(value)', options?)
Full pattern grammar:
<METHOD> ::= GET | POST | PUT | DELETE | PATCH | HEAD | OPTIONS
/path ::= any URL path segment, e.g. /api/users or /api/items/:id
?params ::= zero or more query parameters in URL query-string format
key ::= parameter name
type ::= any string tag (informational, used for documentation)
(value) ::= optional default or example value
The full $path argument is also interpreted as the route schema string stored in IRoute.schema. This is used by list-routes and Swagger-style generators.
Parameter Parsing
Query parameters declared in the path string are parsed into BaseRouteArgument entries stored in IRouteOptions.args. The framework passes URL query-string values to the method in the order they are declared.
// Route: GET /api/items?id=string&limit=number(10)
// Method signature must match the parameter count (or less):
@Route("GET /api/items?id=string&limit=number(10)")
public GetItems($id : string, $limit : number) : any[] {
// ...
}
Parameter count validation: the number of declared query parameters must not exceed the method's declared parameter count. Passing more declared args than the method accepts throws at resolution time.
IRouteOptions
interface IRouteOptions {
httpMethod? : HttpMethodType; // Overrides the method parsed from the path string.
args? : (string | BaseRouteArgument)[]; // Explicit arg list (overrides URL-parsed args).
inputFormatter? : BaseResponseFormatter; // Transforms request body before passing to method. Default: ToJSONRouteFormatter for POST/PUT, BaseResponseFormatter for GET/DELETE.
outputFormatter?: BaseResponseFormatter; // Transforms return value before sending. Default: BaseResponseFormatter.
responseType? : ExternResponseType; // Response handling mode (same as @Extern).
responseHandler?: BaseResponse; // Custom IResponse wrapper class.
autoResponse? : boolean; // Deprecated — migrate to responseType.
}
BaseRouteArgument
BaseRouteArgument holds a parsed query parameter:
class BaseRouteArgument {
constructor($key : string, $value : string)
// $key — parameter name (e.g. "id")
// $value — type tag with optional default, e.g. "string" or "number(10)"
}
Route Discovery
All @Route-decorated methods register themselves in globalThis.oidisAPIRoutes at class evaluation time. Each entry is a factory function that returns an IRoute:
interface IRoute {
route : string; // Path segment, e.g. "/api/items"
schema : string; // Full original schema string
options : IRouteOptions;
ClassName : () => string; // Owning class name
methodName : () => string; // "ClassName.methodName"
method : (...args) => IAuthPromise;
}
RESTResolver.getRoutes() iterates globalThis.oidisAPIRoutes, calls each factory, and groups the resulting IRoute objects by their path for O(1) dispatch lookup.
Route Priority
Routes are matched in registration order. More specific paths should be decorated on methods that appear earlier in the class (or in classes loaded earlier by ForceCompile.ts). There is no automatic specificity ranking — the first matching route wins.
Resolver with @Route Methods
@Resolver(["/api/resource"]) registers a class as an HTTP resolver for one or more URL prefixes. The resolver's Resolve() method is called for every matching request.
A resolver that also hosts @Route methods is a common pattern:
import { Resolver } from "@io-oidis-commons/...";
import { Route } from "../Primitives/Decorators.js";
import { HttpMethodType } from "@io-oidis-commons/...";
import { BaseResolver } from "./BaseResolver.js";
@Resolver(["/api/products"])
export class ProductResolver extends BaseResolver {
/**
* Returns all products.
* @returns {object[]} Returns an array of product objects.
*/
@Route("GET /api/products")
public GetAll() : object[] {
return [...];
}
/**
* Returns a single product by ID.
* @param {string} $id Product identifier.
* @returns {object} Returns the product object.
*/
@Route("GET /api/products/detail?id=string")
public GetById($id : string) : object {
return { id: $id };
}
/**
* Creates a new product from the request body.
* @param {object} $body Parsed JSON body.
* @returns {object} Returns the created product.
*/
@Route("POST /api/products")
public Create($body : object) : object {
// $body is automatically deserialized via ToJSONRouteFormatter
return { ...(<any>$body), id: "new-id" };
}
/**
* Deletes a product.
* @param {string} $id Product identifier.
* @returns {boolean} Returns true on success.
*/
@Route("DELETE /api/products?id=string")
public Delete($id : string) : boolean {
return true;
}
}
Listing Routes at Runtime
Run the server with list-routes to print all registered routes to stdout:
oidis run list-routes
This calls ProgramArgs.ListRoutes(true) which triggers RESTResolver.getRoutes() and formats all routes as a table.
Auth Integration in Routes
Like @Extern, @Route methods receive the resolved auth token via getCurrentToken() after the framework resolves the IAuthPromise:
@Route("GET /api/secure?resource=string")
public GetSecure($resource : string) : any {
const token : string = this.getCurrentToken();
// Validate token or pass to service layer
return { resource: $resource, token };
}
The IAuthPromise.auth field is set to "ClassName.methodName" and is used by the auth middleware to look up the required permission scope for the route.
See also: Resolver Decorator | LCP and RPC | Authentication | Navigation