LCP & RPC
The LiveContentProtocol (LCP) is the bidirectional RPC mechanism that connects the Oidis frontend (browser) to the backend (Node.js). Instead of manually building REST endpoints and HTTP clients, you decorate backend methods with @Extern() and call them from typed frontend connectors.
How It Works
The LCP request cycle involves six components working together:
┌─────────────┐ asyncInvoke() ┌──────────────────┐
│ Frontend │ ──────────────────→ │ LCP Transport │
│ Connector │ │ /liveContent/ │
└─────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ LiveContent │
│ Resolver │
└──────────────────┘
│
▼
┌──────────────────┐
│ AuthManager │
│ (permission check)│
└──────────────────┘
│
▼
┌──────────────────┐
│ Backend │
│ @Extern method │
└──────────────────┘
Step by step:
- Frontend connector calls
asyncInvoke("MethodName", args)— serializes parameters and sends them via HTTP or WebSocket to/liveContent/ - LCP Transport delivers the request to the backend
LiveContentResolver - LiveContentResolver uses reflection to find the
@Externmethod by namespace + method name - AuthManager checks whether the current user may call this method —
IsAuthorizedFor("<Namespace>.<Class>.<Method>", token)runs before the method - Backend method executes — queries the database, calls external APIs, processes business logic
- Response is serialized and sent back through the same transport to the frontend connector
The @Extern Decorator
@Extern() marks a backend method as remotely callable from the frontend. It lives in io-oidis-localhost.
import { BaseConnector } from "@io-oidis-hub/Io/Oidis/Hub/Primitives/BaseConnector.js";
import { Extern } from "@io-oidis-localhost/Io/Oidis/Localhost/Primitives/Decorators.js";
export class InventoryManager extends BaseConnector {
@Extern()
public async ListOrders($offset : number, $limit : number) : Promise<IModelListResult<IOrderView>> {
const filter : any = {deleted: {$ne: true}};
const size : number = await OrderRecord.Model().countDocuments(filter);
const records : OrderRecord[] = await OrderRecord.Find(filter, {limit: $limit, offset: $offset});
return {data: records.map(($record : OrderRecord) : IOrderView => this.toView($record)), limit: $limit, offset: $offset, size};
}
@Extern()
public async SaveOrder($input : IOrderView) : Promise<string> {
let record : OrderRecord = ObjectValidator.IsEmptyOrNull($input.id) ? null : await OrderRecord.FindById($input.id);
if (ObjectValidator.IsEmptyOrNull(record)) {
record = new OrderRecord();
}
record.Note = $input.note; // copy fields explicitly, never save the client payload as a model
await record.Save();
return record.Id;
}
@Extern()
public async DeleteOrder($id : string) : Promise<void> {
const record : OrderRecord = await OrderRecord.FindById($id);
if (!ObjectValidator.IsEmptyOrNull(record)) {
await record.Delete(); // Soft delete
}
}
}
Authorization Happens Before the Method
@Extern methods are plain async methods — there is no auth wrapper to call. The LCP resolver checks the method permission
(AuthManager.IsAuthorizedFor) before invoking the method, and an unauthorized caller gets NOT_AUTHORIZED without the
method ever running. The check matches only the method name; any rule that depends on the arguments (record ownership,
organization, role) must be implemented inside the method, typically with this.context of the Hub BaseConnector.
See @Extern.
@Extern Response Types
The @Extern() decorator accepts an optional response type:
| Type | Behavior |
|---|---|
DEFAULT | Automatic response handling (most common) |
IMPLICIT_RETURN | Return value is sent directly to the client |
FULL_CONTROL | You manage the IResponse object manually |
WITH_PROGRESS | Supports progress updates during execution |
RESPONSE_PROTOCOL | Low-level protocol control |
For most cases, @Extern() with no arguments (DEFAULT) is sufficient.
What @Extern Generates
Behind the scenes, the @Extern() decorator creates a sibling method named MethodName_Extern that returns an IAuthPromise — {auth, resolve}, where auth is the fully qualified method name. This generated method is what LiveContentResolver actually calls: it checks auth with IsAuthorizedFor, then resolve() binds the caller's token and runs your method with the deserialized arguments.
Frontend Connectors
The frontend side of LCP is a connector class that mirrors the backend's API with typed methods.
import { BaseConnector } from "@io-oidis-services/.../BaseConnector.js";
export class InventoryManagerConnector extends BaseConnector {
public static getClient() : InventoryManagerConnector {
const instance = new InventoryManagerConnector(
true,
Loader.getInstance().getHttpManager().CreateLink("/connector.config.jsonp")
);
this.getClient = () => instance;
return instance;
}
protected getServerNamespaces() : any {
const namespaces : any = {};
namespaces[WebServiceClientType.HUB_CONNECTOR] = "Io.Oidis.YourProject.Utils.InventoryManager";
return namespaces;
}
protected getClientType() : WebServiceClientType {
// Auto-detects: web → HUB_CONNECTOR, desktop → DESKTOP_CONNECTOR, etc.
return super.getClientType();
}
// Typed wrapper methods
public async ListOrders($offset : number, $limit : number) : Promise<IModelListResult<IOrderView>> {
return await this.asyncInvoke("ListOrders", $offset, $limit);
}
public async SaveOrder($input : IOrderView) : Promise<string> {
return await this.asyncInvoke("SaveOrder", $input);
}
public async DeleteOrder($id : string) : Promise<void> {
return await this.asyncInvoke("DeleteOrder", $id);
}
}
Key Connector Components
getServerNamespaces() — Returns a plain object mapping WebServiceClientType keys to backend class namespaces. This tells LCP which backend class to route requests to.
getClientType() — Detects the client environment. Most projects use HUB_CONNECTOR for web and DESKTOP_CONNECTOR for Electron/JxBrowser apps.
asyncInvoke() — The core RPC call. Takes a method name (string matching the backend @Extern method) and arguments. Handles serialization, transport, and deserialization automatically.
The Singleton Pattern
Connectors use a memoized singleton via getClient():
public static getClient() : MyConnector {
const instance = new MyConnector(true, /* config */);
this.getClient = () => instance; // Replace method with closure
return instance;
}
After the first call, getClient() is replaced with a closure that always returns the same instance. This avoids the overhead of if (instance === null) checks on every call.
Using Connectors in Controllers
export class OrdersPanelController extends BasePanelController<OrdersPanel> {
private readonly client : InventoryManagerConnector = InventoryManagerConnector.getClient();
public async FetchData() : Promise<void> {
// The table's BindData fetch callback calls this.client.ListOrders($offset, $limit)
await this.getInstanceOwner().ordersTable.ShowData(true);
}
}
ConnectorsRegister — Backend Instance Management
The backend maintains a per-session connector registry. Each authenticated user session gets its own set of connector instances, cached in the ConnectorsRegister:
ConnectorsRegistermaps session tokens to connector instances- First call creates the instance; subsequent calls reuse it
setSymbolMapping()in the Loader enables namespace aliases
// In backend Loader
ConnectorsRegister.setSymbolMapping(
"*.AuthManager",
"Io.Oidis.YourProject.Utils.AuthManager"
);
This means when LCP resolves *.AuthManager, it finds your project's specific AuthManager class.
Symbol mapping should only be used when overriding or extending a connector that already exists in a parent namespace (e.g. AuthManager, AuthCodeManager, EnvironmentHelper). In that case, existing singletons elsewhere in the system may reference the parent namespace, and the symbol ensures they resolve to your overridden class instead.
Do NOT register symbols for new project-specific connectors (e.g. DataManager, InventoryManager). These have their own unique namespace defined in getServerNamespaces() on the frontend connector, and LCP resolves them directly. Registering unnecessary symbols:
- Adds overhead to the permission system, which evaluates all symbol entries
- Can cause ambiguous permission grants (a wildcard symbol like
*.DataManagercould match unintended classes) - Provides no benefit — the connector already knows its full namespace
Rule of thumb: If you created the connector class from scratch and it doesn't override a hub/framework parent, it does not need a symbol.
WebServiceClientType
The client type determines how the connector routes its requests:
| Type | Environment | Usage |
|---|---|---|
HUB_CONNECTOR | Standard web browser | Most common — web apps |
DESKTOP_CONNECTOR | JxBrowser / Electron | Desktop applications |
BUILDER_CONNECTOR | Oidis Builder IDE | Development tooling |
REST_CONNECTOR | REST API client | Programmatic access |
FORWARD_CLIENT | Proxy/forwarding | Service-to-service |
HOST_CLIENT | Host system | System-level integration |
Hub connectors can map multiple types to different backend classes, enabling multi-target routing (e.g., web users hit one backend, desktop users hit another).
@Extern + @Route — Dual Access
A backend method can be decorated with both @Extern and @Route, giving it two access paths:
@Extern()
@Route("GET /api/orders?offset=int(0)&limit=int(50)")
public async ListOrders($offset : number, $limit : number) : Promise<IModelListResult> {
// Same method accessible via LCP and REST
}
- LCP — Frontend connectors call via
asyncInvoke() - REST — External clients call via
GET /api/orders?offset=0&limit=50
Both paths go through the same AuthManager permission check. The developer decides per-method whether to expose via LCP, REST, or both.
Authorization Flow
Every @Extern call goes through the AuthManager before execution:
LiveContentResolverreceives the request- Calls
IsAuthorizedFor()on theAuthManagerwith the fully qualified method name - AuthManager checks the method against three permission categories:
- FOR_ALL_USERS — Methods like Login, Register (no auth required)
- ONLY_FOR_AUTHORIZED_USERS — Default for authenticated users
- System permissions — Role-based restrictions (admin-only methods)
// In AuthManager constructor — any signed-in user (category, class, methods)
this.generateAuthMethodsFrom<InventoryManager>(AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS,
InventoryManager, ($proto : InventoryManager) => [$proto.ListOrders, $proto.SaveOrder]);
// Only a system group — the 2-argument form returns patterns for addSystemPermissions
this.addSystemPermissions("InventoryAdmin",
...this.generateAuthMethodsFrom<InventoryManager>(InventoryManager, ($proto : InventoryManager) => [$proto.DeleteOrder]));
Creating a New Connector — Checklist
- Backend class — Extend
BaseConnector(preferably fromio-oidis-hub, which addsthis.context), add@Extern()methods - Frontend class — Extend
BaseConnector(fromio-oidis-services), overridegetServerNamespaces(), add typedasyncInvoke()wrappers - AuthManager — Register new
@Externmethods with appropriate permission category - ForceCompile — Backend class is auto-added by the Builder (no manual action needed)
- Loader — Add
setSymbolMapping()only if the connector overrides a parent-namespace class (see caution above)
Deep Dive
For further details on specific LCP subsystems, see the dedicated reference pages:
- @Extern decorator mechanics — How
@Externwraps methods, authorization flow andthis.context, response handling modes, custom output formatting - LCP Protocol Internals — Wire format (
IWebServiceProtocol), response deserialization, error propagation, event polling, callback chain API - Connectors Reference — Frontend
BaseConnectorarchitecture, transport clients, construction options, all 16 built-in connectors
See also: Extern Decorator | LCP Protocol Internals | Connectors Reference | Routing