π Back to contents | π Getting started | ποΈ Data modeling, storage, and access | 𧩠Application server features
Metarhia features an auto-loader for its codebase. Upon application start, it automatically loads all layers of code and dependencies, forming namespaces that are accessible from the application code. After loading is complete, it triggers start
hooks. If files change on the disk, the application server will reload the new version on the fly without stopping the server. No connections will be broken, and no API calls will be terminated.
In Metarhia applications, you cannot access the global
object at all, it is hidden and frozen to prevent state manipulation (i.e., the global
object doesn't have a global
field containing a recursive reference to itself, and you can't add or delete fields in it). However, there are multiple stateless identifiers available in the global
context, such as setTimeout
, setImmediate
, setInterval
, clearTimeout
, clearImmediate
, clearInterval
, queueMicrotask
, AbortController
, AbortSignal
, fetch
, console
(which has the same interface as the native Node.js Console
but a different implementation), Buffer
, Blob
, URL
, WebAssembly
, TextEncoder
, TextDecoder
, MessageChannel
, MessageEvent
, MessagePort
, and BroadcastChannel
. Identifiers like require
, import
, module
, and exports
are not available in Metarhia applications because all modules are automatically loaded into namespaces during application initialization. The available namespaces (generated by auto-loader) are api
, bus
, domain
, lib
, application
, node
, npm
, and metarhia
.
Let's introduce two basic concepts:
endpoint
- a single API method or RPC procedure to be invoked from browser-side app or third-party apps. Endpoint has a contract or signature and a name.unit
- group of endpoints (an interface). Unit has a name, and may have a version (e.g.chat.1
,chat.2
,auth
).
In order to provide the best developer experience for rapid API development, Metarhia offers auto-routing for API requests and webhooks. There's no need to manually add routes; all calls made over supported protocols (HTTP, HTTPS, WS, WSS) will be automatically directed to endpoints
based on file system paths. The format of request and response payloads is defined by the Metacom protocol specification and implemented in the npm package Metacom. Metarhia supports automatic request concurrency control, including request execution timeouts and an execution queue with both timeout and queue size limitations. API calls can have contracts (schemas) for automatic input and output data validation. The application server provides isolation for code execution; for more details, see isolation. The application server also supports various API styles: RPC over AJAX, RPC over Websocket, REST, and webhooks.
To create API endpoint put file getCity.js
to folder application/api/example
with following source:
async ({ cityId }) => {
if (cityId !== 1) return new Error('Not found');
return { name: 'Rome', area: 1285, region: 'Lazio' };
};
- Now you can start the server:
node server.js
- Open the browser and DevTools (F12)
- On the
Console
tab and write:await api.example.getCity({ cityId: 1 });
- You will get:
metacom.js:18 Uncaught Error: Forbidden
- You need either to deploy a database for the auth subsystem or remove restrictions to access this method
- Add
access: 'public'
so endpoint will look like this:
({
access: 'public',
async method({ cityId }) {
if (cityId !== 1) return new Error('Not found');
return { name: 'Rome', area: 1285, region: 'Lazio' };
},
});
- Call again:
await api.example.getCity({ cityId: 1 });
- You will get:
{ name: 'Rome', area: 1285, region: 'Lazio' }
- Now let's add a contract schema (add
parameters
andreturns
keys):
({
access: 'public',
parameters: {
cityId: 'number',
},
async method({ cityId }) {
if (cityId !== 1) return new Error('Not found');
return { name: 'Rome', area: 1285, region: 'Lazio' };
},
returns: {
name: 'string',
area: 'number',
region: 'string'
},
});
- Let's try to call:
await api.example.getCity({ cityId: '1' });
- You will get an error, because
cityId
is astring
- Try to return an object with the wrong structure from an endpoint
Handler file can have following optional fields:
caption: string
- method display namedescription: string
- method descriptionaccess: string
(default:logged
) - method access definitionparameters: Schema
- parameters declarative schemavalidate: function
- parameters imperative validation functiontimeout: number
- execution timeout in millisecondsqueue: { concurrency: number, size: number, timeout: number }
- maximum number of concurrent requests, queue size, and timeoutdeprecated: boolean
- to mark deprecated methods withtrue
in API unitsmethod: function
- async/await or promise-returning functionreturns: Schema
- returning value declarative schemaexamples: Array<object>
- array of examplesexample: object
- single example (shorthand)errors: Array<object>
- collection of possible error
Example method with timeout and queue:
({
timeout: 1000,
queue: {
concurrency: 1,
size: 200,
timeout: 3000,
},
method: async ({ a, b }) => {
const result = a + b;
return result;
},
});
Example with external schemas:
({
parameters: {
person: 'Person', // from application/schemas/Person.js
address: 'Address', // from application/schemas/Address.js
},
method: async ({ person, address }) => {
const addressId = await api.gs.create(address);
person.address = addressId;
const personId = await api.gs.create(person);
return personId;
},
returns: 'number',
});
There is a difference between error and exception. Error is a normal result and regular application behaviour that should not break execution sequence while exception breaks execution sequence (serving client-side request or certain asynchronous business-logic scenario).
Return error from method:
(async ({ ...args }) => {
const data = await doSomething();
if (domain.module.validate(data)) {
return data;
} else {
return new Error('Data is not valid', 10);
}
});
Metacom packets:
- with data
{"callback":1,"result":{"key":"value"}}
- or serialized error:
{"callback":1,"error":{"message":"Data is not valid","code":10}}
Example with raise exception:
- Application server will return HTTP 500 or abstract RPC error code.
- Application server may transfer exceptions over the network without stack trace and sensitive error messages (all this data will be logged instead).
(async ({ ...args }) => {
throw new Error('Method is not implemented');
});
Result with exception (metacom packet): {"callback":1,"error":{"message":"Internal Server Error","code":500}}
How to override error codes: throw new Error('Method is not implemented', 404);
This will take error message from code: {"callback":1,"error":{"message":"Not found","code":404}}
If you specify unknown code like this: throw new Error('Method is not implemented', 12345);
this will generate: "Internal Server Error"
with "code":500
.
Metarhia abstracts away the network protocol layer from the developer on both the client and server sides. You can invoke server methods as if they are simple functions in your client-side (or browser) application.
The server spawns separate threads for:
- The load balancer (always HTTP). However, you can disable the built-in load balancer in the configuration. The balancer redirects incoming traffic to one of the open ports using a round-robin algorithm for simple scaling. Alternatively an external balancer can be used
- Each port (HTTP, HTTPS)
Metarhia provides promise-based abstraction for RPC calls implemented in metacom.
const metacom = Metacom.create('https://domainname.com:8001');
await metacom.load('auth', 'chat');
const { auth, chat } = metacom.api;
chat.on('message', (event) => {
console.log(event.message);
});
await auth.signIn({ login: 'marcus', password: 'marcus' });
await chat.subscribe({ room: 'Room1' });
await chat.send({ room: 'Room1', message: 'Hello' });
There is a special place for domain logic and application state: application/domain
. You can group code in modules (files) and folders. For example put following to chat.js
in mentioned folder:
({
rooms: new Map(),
getRoom(name) {
let room = domain.chat.rooms.get(name);
if (room) return room;
room = new Set();
domain.chat.rooms.set(name, room);
return room;
},
dropRoom(name) {
domain.chat.rooms.delete(name);
},
send(name, message) {
const room = domain.chat.rooms.get(name);
if (!room) throw new Error(`Room ${name} is not found`);
for (const client of room) {
client.emit('chat/message', { room: name, message });
}
},
});
The methods and public properties of the module will be available from another application layers through the domain.chat
namespace. For example it made possible a thin API endpoint application/api/chat/send.js
that will deliver a message from one chat room participant to all others without handling a state by itself:
(async ({ room, message }) => {
domain.chat.send(room, message);
return true;
});
Auxiliary code that is not related to subject domain, but we don't want to create or import separate dependencies for it, can be placed in: application/lib
. For example:
({
UNITS: ['', ' Kb', ' Mb', ' Gb', ' Tb', ' Pb', ' Eb', ' Zb', ' Yb'],
bytesToSize(bytes) {
if (bytes === 0) return '0';
const exp = Math.floor(Math.log(bytes) / Math.log(1000));
const size = bytes / 1000 ** exp;
const short = Math.round(size, 2);
const unit = this.UNITS[exp];
return short + unit;
},
});
You can access node.js internal modules and third-party dependencies with namespaces:
node.
, e.g.node.fs.readFile(filePath, callback);
npm.
, e.g.const client = npm.redis.createClient();
metarhia.
, e.g.const metacom = metarhia.metacom.Metacom.create('http://127.0.0.1:8001/api');
To add new dependency just use npm install
command e.g. npm i ws
and ws
will be available as npm.ws
after next (re)start.
Mapping remote services to namespaces of your application is easy, just put file .service.js
in application/bus/worldTime
:
({
url: 'http://worldtimeapi.org/api'
});
and add file currentTime.js
near it:
({
method: {
get: 'timezone',
path: ['area', 'location'],
},
});
Now you can call bus.worldTime.currentTime
from anywhere in the application:
try {
const time = await bus.worldTime.currentTime({
area: 'Europe',
location: 'Rome',
});
console.log(`${time.timezone} - ${time.datetime}`);
} catch {
console.log('Can not access time server');
}
This will send HTTP GET request to http://worldtimeapi.org/api/timezone/Europe/Rome
You can initialize database connection on application start from application/db
for example putting start
hook to application/db/geo/start.js
:
async () => {
db.geo.pg = new npm.pg.Pool(config.database);
};
After that you can access db driver from anywhere like:
const res = await db.geo.pg.query('SELECT * from CITIES where ID = $1', [25]);
console.log(res.rows[0]);
More details with examples in: /content/en/DATA.md
π Back to contents | π Getting started | ποΈ Data modeling, storage, and access | 𧩠Application server features