JavaScript Plugins — Custom Backend Logic Without Java

Write server-side services and interceptors in JavaScript, with hot-reload

Facet runs on RESTHeart, which supports server-side plugins written in JavaScript using GraalVM. You can add custom endpoints, data aggregations, and request/response hooks without writing Java and without a compilation step. Edit a .mjs file, send a request, see the result.

Facet 1.1.0 runs on RESTHeart 9.12.3. Its integration tests load the example plugins below against that version.

For the full runtime API reference, see the RESTHeart JavaScript plugins documentation.

Why JavaScript Plugins?

Aspect JavaScript plugin Java plugin
Language JavaScript / TypeScript (ESM) Java
Compilation None mvn package + restart
Hot-reload Yes (next request) No
MongoDB access mclient global (GraalVM interop) MongoClient injection
Deployment Mount a directory Copy JAR

Two extension points are available: Services (new HTTP endpoints) and Interceptors (hooks into existing request/response lifecycle).

Writing a Service

A service is an ES module that exports an options object and a handle function.

// hello.mjs
export const options = {
    name: "helloService",
    description: "A minimal example",
    uri: "/api/hello",
    secured: true,
    matchPolicy: "EXACT"
};

export function handle(request, response) {
    const result = { message: "Hello from JavaScript!" };
    response.setContent(JSON.stringify(result));
    response.setContentTypeAsJson();
}

Querying MongoDB

RESTHeart injects a mclient global (a MongoClient instance). Use Java.type() to import BSON classes.

const BsonDocument = Java.type("org.bson.BsonDocument");

export function handle(request, response) {
    const db   = mclient.getDatabase("mydb");
    const coll = db.getCollection("mycollection", BsonDocument.class);

    let results = [];
    const it = coll.find().limit(10).iterator();
    while (it.hasNext()) {
        results.push(JSON.parse(it.next().toJson()));
    }

    response.setContent(JSON.stringify(results));
    response.setContentTypeAsJson();
}

Common BSON accessor methods:

Type Method
String doc.getString("key").getValue()
Number doc.getNumber("key").doubleValue()
Integer doc.getNumber("key").intValue()
Boolean doc.getBoolean("key").getValue()
Key exists doc.containsKey("key")

Writing an Interceptor

An interceptor hooks into the request/response lifecycle for existing endpoints. It exports a resolve predicate that controls which requests it applies to.

// add-header.mjs
export const options = {
    name: "addHeader",
    description: "Adds a custom response header",
    interceptPoint: "RESPONSE"
};

export function resolve(request) {
    return request.getPath().startsWith("/api/");
}

export function handle(request, response) {
    const HttpString = Java.type("io.undertow.util.HttpString");
    response.getHeaders().add(HttpString.tryFromString("X-Powered-By"), "Facet");
}

Available intercept points: REQUEST_BEFORE_AUTH, REQUEST_AFTER_AUTH, RESPONSE, RESPONSE_ASYNC.

Plugin Manifest

Each plugin directory needs a package.json that declares its services and interceptors:

{
  "name": "my-plugin",
  "version": "1.0.0",
  "rh:services": ["hello.mjs"],
  "rh:interceptors": ["add-header.mjs"]
}

RESTHeart scans plugin directories at startup and loads any .mjs files listed in rh:services or rh:interceptors.

Folder Layout and Deployment

plugins/
└── my-plugin/
    ├── package.json
    ├── hello.mjs
    └── add-header.mjs

Mount the plugin directory in Docker Compose:

services:
  facet:
    image: softinstigate/facet:latest
    volumes:
      - ./plugins/my-plugin:/opt/restheart/plugins/my-plugin:ro

Edit any .mjs file while the stack is running. The next HTTP request picks up the change automatically. No restart, no build.

Pairing with Facet Templates

A JavaScript service returns JSON. When a browser sends Accept: text/html, Facet intercepts the response and renders an HTML template. The template path follows the same convention as MongoDB endpoints: a service at /shop/stats resolves to templates/shop/stats/index.html.

Every top-level JSON key becomes a template variable. The raw JSON string is also available as {{ data }}. Facet's own variables (isAuthenticated, username, roles, requestMethod, path, data) always win, so don't use those names as keys in your response. If your service returns:

{ "total": 42, "avgPrice": 309.49 }

The template receives {{ total }} and {{ avgPrice }} directly:

{% extends "layout" %}
{% block main %}
<h1>Statistics</h1>
<p>Total products: {{ total }}</p>
<p>Average price: ${{ avgPrice }}</p>
{% endblock %}

The same endpoint serves both formats:

# HTML (browser)
curl -H "Accept: text/html" http://localhost:8080/shop/stats

# JSON (API client)
curl http://localhost:8080/shop/stats

Working Example

The product-catalog example includes a complete JavaScript plugin at plugins/product-stats/:

File Purpose
product-stats.mjs Aggregates product stats from MongoDB
package.json Plugin manifest
templates/shop/stats/index.html HTML dashboard template

Run it:

cd examples/product-catalog
docker compose up

Then open http://localhost:8080/shop/stats (login: admin / secret).

Further Reading