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
- RESTHeart JavaScript plugins — full runtime API, TypeScript support, advanced examples
- JavaScript plugins reference — complete guide in the Facet repository
- Developer's Guide — how plugins integrate with Facet's template system
- Template System — template resolution algorithm