Framework
Develop Plugins in JavaScript
Introduction
Services and Interceptors can be written in JavaScript or TypeScript, with no Java at all. They run inside RESTHeart on GraalVM, or in the RESTHeart native image, and they hot deploy: copy the plugin directory into the plugins directory and it is live, change a file and it is redeployed.
Since 9.14.0 a plugin imports what it needs from the restheart:sdk module: the request and response as plain objects, a logger, crypto and encode, the MongoDB client, an HTTP client, its configuration. The types come from the npm package @restheart/sdk, so req and res autocomplete in the editor.
import { defineService, mongo, logger, BadRequestError } from "restheart:sdk";
export default defineService({
name: "usersService",
description: "lists users",
uri: "/users",
contentKind: "json",
secured: true,
handle(req, res) {
const limit = Number(req.query.limit ?? 100);
if (Number.isNaN(limit)) throw new BadRequestError("wrong limit qparam");
logger.debug("listing users for {}", req.account?.name);
res.json(mongo.db("restheart").collection("users")
.find({}, { limit, sort: { _id: -1 } })
.toArray());
},
});
This page covers what every plugin shares: the execution model, the packaging, the build for TypeScript and the deployment. The other pages of this section cover the parts:
-
Services:
defineService,contentKind, the request and the response, errors -
Interceptors:
defineInterceptor, intercept points,resolve, themongoandcsvkinds -
Utilities:
logger,crypto,encode,resource,bson, extended JSON and the number rules -
Capabilities:
http,mongo,config,mail,providers
|
Tip
|
The example plugins are written in TypeScript and cover every part of the SDK. |
|
Note
|
The RESTHeart native binaries can be extended with JavaScript plugins only. A Java plugin needs RESTHeart and the plugin built together with native-image, see Build RESTHeart with custom plugins as native image.
|
The execution model
This is where the instincts of a JavaScript developer are wrong, so it comes first.
Each request runs in its own virtual thread. There is no event loop and no need to keep it free: a blocking call is the normal and correct way to do I/O. handle(req, res) is synchronous, its return value is ignored, what matters is what it did to res. Promises, async and await have no place in a plugin: http.get() returns the response, coll.find().toArray() returns the documents, and while a call waits the virtual thread parks and the server keeps serving other requests.
The contexts a plugin runs in are pooled and recycled between requests. A variable at module level is not a server-wide cache: there is one copy per context, a context can be closed at any time, and two requests in a row may run in different contexts. For state that must be shared and survive, use a MongoDB collection, with a TTL index when it has to expire. That is also correct across a multi-node deployment, which an in-memory cache never is.
What a plugin hands to the runtime is kept as data, not as objects: no functions, no closures, and mutating a value read from the runtime does not mutate what the runtime holds.
Packaging
A plugin is a directory with a package.json that lists its modules:
{
"name": "my-plugin",
"rh:spi": 1,
"rh:services": [ "users.mjs", "webhook.mjs" ],
"rh:interceptors": [ "audit.mjs" ]
}
-
rh:servicesandrh:interceptorslist the modules, with paths relative to the directory; a subdirectory is fine -
rh:spinames the SDK major the plugin is written for. RESTHeart refuses a plugin written for a future major, with a line in the log, instead of deploying it and failing at request time. The current major is 1
One directory can hold any number of services and interceptors, and they can share code through ordinary relative imports.
npm packages
A plugin can use npm packages: run npm install in the plugin directory and import them. Only pure JavaScript packages run. GraalJS has no Node built-ins, so a package that needs fs, crypto, http or zlib fails, and so does a package whose dependency starts needing one in a patch release. What those built-ins give is in the SDK instead: resource reads files, crypto signs and hashes, http calls other services.
TypeScript
Write the plugin in TypeScript and compile it with tsc before deploying. RESTHeart deploys the compiled .mjs files and does not transpile: compiling is a build step, like npm install, and it keeps the deploy fast.
npm install --save-dev @restheart/sdk typescript
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"strict": true,
"types": ["@restheart/sdk"],
"rootDir": "src",
"outDir": "."
},
"include": ["src/**/*.mts"],
"exclude": ["node_modules"]
}
A src/users.mts source compiles to users.mjs, the file package.json lists. tsc --watch recompiles on every change and RESTHeart redeploys the plugin when the .mjs file changes.
@restheart/sdk contains type declarations only. It is versioned in lockstep with RESTHeart: @restheart/sdk 9.14.0 declares what RESTHeart 9.14.0 provides. A plugin written in plain JavaScript needs none of this: .mjs files and package.json are enough.
Deploy
Copy the plugin directory into the plugins directory of RESTHeart. With Docker, mount it:
docker run --rm -p 8080:8080 -v ./my-plugin:/opt/restheart/plugins/my-plugin softinstigate/restheart:latest-native
RESTHeart watches the directory: a new plugin directory is deployed, a changed .mjs file is redeployed, a deleted one is undeployed. When package.json appears or changes, the plugins it declares that are not deployed yet are deployed, and the ones it no longer declares are undeployed. No restart.
|
Tip
|
Since version 9.15.2 a plugin directory copied while RESTHeart runs is deployed whole, whatever the order in which its files are created. In earlier versions a .mjs file copied before package.json was not deployed until a restart; on macOS new files were not deployed at all.
|
If a change is not picked up, touching the file forces it. Touching a .mjs file redeploys that plugin, touching package.json deploys the declared plugins that are missing (since version 9.15.2):
touch plugins/my-plugin/users.mjs
touch plugins/my-plugin/package.json
The log says what was deployed, at which uri and with which content kind, or why a plugin was not deployed: a wrong declaration, a missing capability, an rh:spi the server does not have.
Configuration
A plugin reads its own section of restheart.yml, named after the plugin, with the config capability:
usersService:
db: restheart
collection: users
import { config } from "restheart:sdk";
const db = config.db ?? "restheart";
Plugins written before 9.14.0
A plugin that exports options, handle and resolve keeps working unchanged. Its handle receives the Java request and response, as before, and the globals LOGGER, mclient and pluginArgs are still there. They are deprecated, and the log marks such a plugin as legacy when it deploys it.
| deprecated | replacement | notes |
|---|---|---|
|
|
the declaration and the functions in one object |
|
|
|
|
|
null-safe: |
|
|
plain values in and out; |
|
|
|
|
nothing |
the GraalJS option it mapped to was removed in GraalVM 25.1, the value is ignored |
Moving a plugin to the SDK is mechanical: the declaration becomes the default export, request.getContent() becomes req.content, response.setContent(JSON.stringify(x)); response.setContentTypeAsJson() becomes res.json(x). The Java objects stay reachable as req.raw and res.raw, and Java.type() still works, so a plugin can move one call at a time.
Java interoperability
The SDK covers what a plugin writes every day. For the rest, the whole classpath of RESTHeart is one call away: Java.type("org.restheart.utils.BsonUtils") gives a Java class, req.raw and res.raw are the Java request and response, mongo.raw() is the MongoClient, and providers["name"] is any registered provider. This is a convenience layer, not a sandbox.
Running on Node
RESTHeart can also run on GraalVM Node, with plugins declared in rh:node-services. That mode is experimental, it does not use restheart:sdk, and it runs the plugins on the Node event loop with a different execution model. See examples/node-plugin.