Skip to content

Latest commit

Β 

History

History
316 lines (239 loc) Β· 12 KB

LAYERS.md

File metadata and controls

316 lines (239 loc) Β· 12 KB

πŸ₯ž Application server layers

πŸ‘‰ 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.

API

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 and returns 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 a string
  • Try to return an object with the wrong structure from an endpoint

Handler file can have following optional fields:

  • caption: string - method display name
  • description: string - method description
  • access: string (default: logged) - method access definition
  • parameters: Schema - parameters declarative schema
  • validate: function - parameters imperative validation function
  • timeout: number - execution timeout in milliseconds
  • queue: { concurrency: number, size: number, timeout: number } - maximum number of concurrent requests, queue size, and timeout
  • deprecated: boolean - to mark deprecated methods with true in API units
  • method: function - async/await or promise-returning function
  • returns: Schema - returning value declarative schema
  • examples: Array<object> - array of examples
  • example: 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',
});

Error-handling guidelines

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.

Network

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' });

Domain logic

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;
});

Libraries

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;
  },
});

Dependencies

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.

Bus

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

Data access

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