Skip to main content

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
})
OptionValuesDescription
typeUNIVERSAL, BACKEND_ONLY, FRONTEND_ONLYWhich side handles the request
scopeALL_PROFILES, PROD, DEVWhen the resolver is active
prioritynumberResolution order (higher = checked first)

ResolverType​

  • UNIVERSAL (default) — Works on both frontend and backend
  • BACKEND_ONLY — Only available on the server (e.g., API controllers)
  • FRONTEND_ONLY — Only available in the browser

ResolverScope​

  • ALL_PROFILES (default) — Active in all environments
  • PROD — Only in production builds
  • DEV — 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:

SyntaxTypeExample
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​

DecoratorPurposeRegistrationAuth Mechanism
@ResolverURL → controller mappingglobalThis.oidisResolversaccessValidator() / AccessValidator() protected methods
@RouteREST API endpointglobalThis.oidisAPIRoutesAuthManager (same as @Extern)
@ExternLCP RPC methodMethodName_Extern wrapperAuthManager (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 shapehttps://app/#/settingshttps://app/settings
Current route sourcewindow.location.hashwindow.location.pathname (minus app root)
What the server receivesalways / (fragment is never sent)the full path
Server cooperationnone neededSPA-shell fallback required (provided automatically)

How the current route is read​

HttpRequestParser.getScriptPath() returns the SPA route:

  • history — window.location.pathname with the application relativeRoot prefix removed and the query stripped.
  • hash — the substring after #.

Event wiring (EventsManager)​

  • hash mode — binds window.onhashchange = hashHandler; one hash change fires exactly one ON_HTTP_REQUEST resolve.
  • history mode — binds three listeners:
    • popstate → hashHandler — back/forward and pushState-driven resolves.
    • hashchange → legacyHashHandler — backward compatibility: a legacy #/route link followed inside a running history-mode SPA is rewritten with replaceState to the clean /route and resolved. Plain #anchor in-page fragments are left untouched.
    • click → anchorInterceptor — intercepts same-origin <a href="…#/route"> clicks and routes them through ReloadTo. 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.

A backend resolver that redirects into a front-end route works in history mode too, by design. Given a backend 302 → /#/route:

  1. The browser loads the SPA shell; the address bar carries the #/route fragment.
  2. On bootstrap the HttpResolver constructor detects the #/route fragment under history mode and replaceStates it to the clean /route before the first resolve.
  3. The initial resolve reads pathname = /route and runs the front-end resolver. Because replaceState is 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 / GraphiQLLoader emit a raw 302 Location: <host>/#/ServerError/....
  • AuthenticationResolver bounces the headless SSO branch to /#/login.
  • McpOAuthConnector bounces a cold browser to /#/mcp/authorize/...; PrintManager drives 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 #/ so pushState receives a clean path (hash mode keeps the #/). Always build SPA links through it.
  • NavigateTo($link) — awaitable SPA navigation (history: pushState + explicit resolve; hash: sets window.location.hash). Same-origin relative links only. Always await it.
  • ReloadTo($link) — SPA navigation for app-relative links (history: pushState + resolve; hash: sets the hash). It falls back to a full window.location.href load 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:

  1. ShowMessage shows the toast live and records it as the most recent message (pendingMessage).
  2. 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).
  3. The target controller's onSuccess sees contentWiped and re-shows the pending message on the freshly rendered page via that page's own ShowMessage, guarded by a replaying flag 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 ForceCompile entries needed.
  • Authorization — @Resolver uses accessValidator() methods; @Route and @Extern use AuthManager.
  • Dual access — Combine @Extern and @Route on the same method for both LCP and REST access.
  • Routing modes — target.historyRouting toggles hash mode (/#/route, default) vs History API mode (clean /route); both resolve through the same @Resolver table.
  • Navigation — NavigateTo() (awaitable) and ReloadTo() 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