JavaScript plug-in API
Write hub logic in JavaScript instead of C. mod_javascript embeds the
QuickJS engine and runs your scripts
as ordinary uhub plug-ins — no compiler, no rebuild, no ABI to match.
New in uhub 0.8.0. mod_javascript is optional and is not
built unless you ask for it — see Building the plug-in.
Why JavaScript?
The C plug-in API is the fast path and gives you the whole hub. It also means a compiler, a shared library that must match the plug-in ABI of the running hub, and a rebuild for every policy tweak. Most hub customisation is not performance-critical — a welcome message, a word filter, a rule about who may search — and for that a script you can edit and reload is a far better fit.
| C plug-in | JavaScript | |
|---|---|---|
| Build step | Compiler, headers, matching plug-in API version | None — drop a .js file in place |
| Hub surface | Everything in plugin_funcs | The curated uhub object |
| Failure mode | A bug can crash or wedge the hub | Exception is logged; watchdog stops runaway loops |
| Sandbox | Full process privileges | No filesystem, network or process access |
| Best for | Storage backends, protocol work, hot paths | Hub policy, chat rules, greetings, moderation |
The two coexist. A hub can load mod_auth_sqlite in C for storage and a handful of
scripts for policy on top.
Building the plug-in
The engine (quickjs-ng) is a git submodule pinned to a specific release. Fetch it, then configure with JavaScript support:
git submodule update --init third_party/quickjs
# CMake
cmake -DJAVASCRIPT_SUPPORT=ON ..
# or zig
zig build -Djavascript=true
A fresh clone can use git clone --recurse-submodules instead. Only the engine core
is compiled — no CLI, and none of QuickJS's POSIX std/os bindings.
Loading scripts
mod_javascript is loaded once, like any other plug-in, from
plugins.conf. Rather than naming every script on that line,
you point it at a directory or a list file:
# Load every *.js in a directory (sorted by name, each validated):
plugin /usr/lib/uhub/mod_javascript.so "dir=/etc/uhub/js.d"
# Or list scripts, with per-script options, in a secondary config file:
plugin /usr/lib/uhub/mod_javascript.so "config=/etc/uhub/javascript.conf"
# A single script is also fine:
plugin /usr/lib/uhub/mod_javascript.so "script=/etc/uhub/welcome.js motd=Hi %n"
dir=, config= and script= may be combined and repeated.
Every other key=value on the plug-in line is handed to all scripts as
uhub.config.
The list file used by config= holds one script per line:
# /etc/uhub/javascript.conf — '#' starts a comment, blank lines are ignored.
welcome.js motd=Welcome %n! # per-script options follow the path
chat_only.js
flood.js grace=5
Relative paths resolve against the list file's own directory, and the per-script
key=value options become that script's uhub.config, overriding
anything set on the plug-in line.
dir= uses dirent and is POSIX-only. On Windows use
config= or script=.
Tunables
These keys are consumed by mod_javascript itself and are not passed to scripts:
| Option | Default | Description |
|---|---|---|
memory_limit | 67108864 | Engine heap cap, in bytes (64 MiB). |
stack_limit | 1048576 | JavaScript stack cap, in bytes (1 MiB). |
time_limit | 1000 | Per-callback wall-clock budget, in milliseconds. |
Your first script
A script registers handlers on the global uhub object. The file is evaluated once
at hub startup; nothing else runs until an event fires.
// greeter.js
var hubName = uhub.config.name || "our hub";
uhub.onUserLogin(function (user) {
uhub.log("login: " + user.nick + " (" + user.credentials + ")");
user.sendMessage("Welcome to " + hubName + ", " + user.nick + "!");
});
uhub.onChatMsg(function (from, message) {
if (/\bbadword\b/i.test(message))
return uhub.DENY; // block this message
// returning nothing == uhub.DEFAULT == let the hub decide
});
Load it and restart the hub:
plugin /usr/lib/uhub/mod_javascript.so "script=/etc/uhub/greeter.js name=Café ADC"
If a script fails to parse, or throws while being evaluated, the hub refuses to start and logs the error. A script is a configuration file: a broken one is a configuration error, not something to be silently skipped.
Events
Every registrar takes a single function; registering again replaces the previous handler. Interceptable events use the handler's return value (see Verdicts); the rest ignore it.
| Registrar | Handler arguments | Intercept | Fires when |
|---|---|---|---|
onUserLogin | (user) | — | A user has completed login and is on the hub. |
onUserLogout | (user, reason) | — | A logged-in user disconnected. reason is a short string. |
onChatMsg | (from, message) | yes | A main-chat message is about to be routed. |
onPrivateMsg | (from, to, message) | yes | A private message is about to be delivered. |
onSearch | (user, data) | yes | A search is about to be forwarded. data is the raw ADC search string. |
onSearchResult | (user, data) | yes | A search result is about to be relayed back. user is the sender. |
onP2PConnect | (from) | yes | A user asks to connect to a peer (active mode, CTM). |
onP2PRevConnect | (from) | yes | A user asks a peer to connect back (passive mode, RCM). |
onCheckIpLate | (user) | yes | Late in the login pipeline. DENY rejects the login. |
onChangeNick | (user, newNick) | yes | A logged-in user wants to rename. ALLOW permits it, DENY refuses. |
onFloodDetected | (user, type) | yes | The hub's flood_ctl_* thresholds tripped. See below. |
onHubStarted | () | — | All plug-ins are loaded and the hub is running. |
onHubShutdown | () | — | The hub is shutting down, before plug-ins are unloaded. |
onFloodDetected receives a type of
"chat", "connect", "search", "update" or
"protocol". The hub only detects the flood — the script decides the
action. Returning DEFAULT keeps the hub's built-in drop-and-warn,
DENY drops the offending message quietly, and ALLOW lets it through.
onUserLogout fires only for users that completed login. Connections
rejected earlier — by onCheckIpLate, a ban, or a failed handshake — never
produce one, so do not use it as your only cleanup path for state you create before login.
Verdicts
An interceptable handler returns one of three values:
| Value | Meaning |
|---|---|
uhub.DEFAULT | Let the hub apply its normal behaviour. Also what undefined — i.e. no return at all — means. |
uhub.ALLOW | Force-allow the action, overriding what the hub would otherwise have done. |
uhub.DENY | Block the action. |
A boolean works too: true is ALLOW and false is
DENY. Falling off the end of a function returns undefined, which is
DEFAULT — so the common “only act on the cases I care about” shape needs no
explicit else branch.
uhub.onPrivateMsg(function (from, to, message) {
if (from.credentials === "guest" && to.credentials === "guest")
return uhub.DENY; // guests may not PM each other
// everything else: no return -> DEFAULT
});
When several scripts handle the same event they run in load order, and the
first non-DEFAULT verdict wins — later scripts are not consulted
for that event. A script that returns ALLOW early therefore shadows a stricter
script loaded after it, which is worth keeping in mind when ordering
dir= entries (they load sorted by filename).
The uhub object
| Member | Description |
|---|---|
uhub.broadcast(text) | Send an informational message from the hub to every logged-in user. |
uhub.getUserCount() | Number of users currently on the hub. |
uhub.log(text) | Write a line to the hub log. |
uhub.config | Object of this script's options, as strings — e.g. uhub.config.motd. Missing keys are undefined. |
uhub.DEFAULT / ALLOW / DENY | Verdict constants (0 / 1 / -1). |
Everything in uhub.config is a string, including numbers and booleans. Parse and
validate at load time rather than on every event:
var grace = parseInt(uhub.config.grace || "3", 10);
if (!(grace >= 1)) grace = 1; // also catches NaN
var strict = uhub.config.strict === "1" || uhub.config.strict === "true";
The user object
Properties are read-only snapshots taken when the handler was called:
| Property | Description |
|---|---|
user.nick | Nickname. |
user.cid | Client ID — the client's identity, stable across reconnects. |
user.userAgent | The client's user-agent string, e.g. "DC++ 0.880". |
user.credentials | "guest", "user", "operator", "super", "admin", … |
user.sid | Session ID. Reused as users come and go — do not use it as a key. |
user.id | Stable, non-recycled connection id. This is the safe Map key. |
Methods:
| Method | Description |
|---|---|
user.sendMessage(text) | Send an informational message from the hub to this user. |
user.sendRichMessage(text) | The same, marked as rich text (RTF0). text is CommonMark. |
user.supportsRichText() | true when this client negotiated RTF0 and the hub allows rich text. |
user.sendStatus(code, text) | Send a status message with an ADC status code (0 = informational). |
user.disconnect() | Kick the user. |
user.ban(seconds, reason) | Ban by CID and nick, disconnect, persist and propagate to linked hubs. seconds <= 0 is permanent; reason may be omitted. |
user.ban() is a real ban, not a kick: it is written through to whichever storage
plug-in is loaded and forwarded across a hub link, so it survives a
restart and applies cluster-wide. Use user.disconnect() when you only want the
user gone for now.
Object lifetime — read this one
A user handed to a handler is valid only for the duration of that
call. Stash it and touch it from a later callback and the access throws
— it does not silently return stale data or dangle.
var lastUser = null;
uhub.onUserLogin(function (user) {
lastUser = user; // storing the reference is fine…
});
uhub.onChatMsg(function () {
lastUser.sendMessage("hi"); // …but this THROWS: the reference is dead
});
That is deliberate. The underlying hub user can disconnect between two events, and a script
holding a pointer to it would be a use-after-free. To carry state across events, key a
Map by user.id — the stable connection id — and drop the entry on
logout:
var strikes = new Map();
uhub.onFloodDetected(function (user, type) {
var n = (strikes.get(user.id) || 0) + 1;
strikes.set(user.id, n);
if (n >= 3) {
user.disconnect();
return uhub.DENY;
}
});
uhub.onUserLogout(function (user) {
strikes.delete(user.id); // or the Map grows without bound
});
Copying the values you need — user.nick, user.cid — out of the object
is fine; they are plain strings and stay valid. It is only the object itself, and its methods,
that expire.
Rich text
With the RTF0 extension a client can render CommonMark. sendRichMessage() falls
back to an ordinary message when the client cannot — but it sends the same string,
markup and all. Whenever the plain and rich forms would differ, branch and build both:
uhub.onUserLogin(function (user) {
if (user.supportsRichText())
user.sendRichMessage("Welcome, **" + user.nick + "**! See the [rules](https://example.org/rules).");
else
user.sendMessage("Welcome, " + user.nick + "! See the rules: https://example.org/rules");
});
supportsRichText() also accounts for the hub-wide
chat_rich_text option, so a hub that has turned rich text off makes
every client report false and no branch needs changing.
Worked examples
Ready-to-run ports of three bundled C plug-ins ship in doc/js/ —
welcome.js (cf. mod_welcome),
chat_only.js (cf. mod_chat_only) and
flood.js (cf. mod_flood), alongside an
example javascript.conf. The recipes below build on them.
A configurable message of the day
Scripts have no filesystem access, so the text comes from the configuration rather than a file.
This is the shape doc/js/welcome.js uses:
// welcome.js — plugin ... "script=/etc/uhub/welcome.js motd=Welcome %n!"
var motd = uhub.config.motd || "Welcome to the hub, %n!";
function expand(template, user) {
return template.replace(/%[nc%]/g, function (m) {
if (m === "%n") return user.nick;
if (m === "%c") return user.credentials;
return "%";
});
}
uhub.onUserLogin(function (user) {
user.sendMessage(expand(motd, user));
});
Chat-only hub, with a one-time warning per user
Deny searches and transfers, but only tell each user once per category so a client retrying in
a loop cannot spam them. Operators are exempt unless operator_override=0:
var operatorOverride = uhub.config.operator_override !== "0";
var warned = new Map(); // user.id -> { search: true, connect: true }
function isOperator(user) {
var c = user.credentials;
return c === "operator" || c === "super" || c === "admin";
}
function denyOnce(user, key, message) {
if (operatorOverride && isOperator(user))
return uhub.ALLOW;
var seen = warned.get(user.id) || {};
if (!seen[key]) {
user.sendStatus(0, message);
seen[key] = true;
warned.set(user.id, seen);
}
return uhub.DENY;
}
uhub.onSearch(function (user) {
return denyOnce(user, "search", "Searching is disabled. This is a chat only hub.");
});
uhub.onSearchResult(function () { return uhub.DENY; });
uhub.onP2PConnect(function (user) {
return denyOnce(user, "connect", "Connection setup denied. This is a chat only hub.");
});
uhub.onP2PRevConnect(function (user) {
return denyOnce(user, "connect", "Connection setup denied. This is a chat only hub.");
});
uhub.onUserLogout(function (user) { warned.delete(user.id); });
Note onSearchResult takes no per-user warning: results are relayed on behalf of
other users, and warning the sender about someone else's search is noise.
Strike-based flood action
The hub detects the flood against its flood_ctl_* thresholds; the script decides
what happens. Below the grace limit, hand back DEFAULT so the hub keeps its
built-in drop-and-warn:
var grace = parseInt(uhub.config.grace || "3", 10);
if (!(grace >= 1)) grace = 1;
var strikes = new Map();
uhub.onFloodDetected(function (user, type) {
if (user.credentials !== "guest" && user.credentials !== "user")
return uhub.ALLOW; // staff are never acted on
var n = (strikes.get(user.id) || 0) + 1;
strikes.set(user.id, n);
if (n >= grace) {
user.sendStatus(0, "Disconnected: repeated " + type + " flooding.");
user.disconnect();
return uhub.DENY; // handled; drop the message quietly
}
return uhub.DEFAULT; // hub warns and drops
});
uhub.onUserLogout(function (user) { strikes.delete(user.id); });
A nick policy
onChangeNick intercepts renames of users already on the hub. Return
ALLOW to permit the rename and DENY to refuse it:
var reserved = /^(?:admin|operator|hub|owner|staff)/i;
uhub.onChangeNick(function (user, newNick) {
if (reserved.test(newNick) && user.credentials === "guest") {
user.sendStatus(0, "That nickname is reserved for staff.");
return uhub.DENY;
}
if (newNick.length < 3) {
user.sendStatus(0, "Nicknames must be at least 3 characters.");
return uhub.DENY;
}
});
Rate-limiting private messages from guests
A sliding window keyed by connection id. Note the time source: Date.now() is
available, and handlers run on the hub's single thread, so no locking is needed.
var WINDOW_MS = 60000, MAX_PMS = 10;
var sent = new Map(); // user.id -> array of timestamps
uhub.onPrivateMsg(function (from, to, message) {
if (from.credentials !== "guest")
return; // DEFAULT: not our business
var now = Date.now();
var stamps = (sent.get(from.id) || []).filter(function (t) {
return now - t < WINDOW_MS;
});
if (stamps.length >= MAX_PMS) {
from.sendStatus(0, "You are sending private messages too quickly.");
sent.set(from.id, stamps);
return uhub.DENY;
}
stamps.push(now);
sent.set(from.id, stamps);
});
uhub.onUserLogout(function (user) { sent.delete(user.id); });
Announcing staff arrivals
uhub.onUserLogin(function (user) {
var c = user.credentials;
if (c === "operator" || c === "super" || c === "admin")
uhub.broadcast(user.nick + " (" + c + ") has joined. " +
uhub.getUserCount() + " users online.");
});
uhub.onHubStarted(function () { uhub.log("staff-announce.js ready"); });
Banning on a hard rule
user.ban() persists and propagates, so use it for rules you mean. The reason is
shown to the user when they try to reconnect:
var spamLink = /(?:https?:\/\/)?(?:bit\.ly|tinyurl\.com)\/\S+/i;
uhub.onChatMsg(function (from, message) {
if (from.credentials !== "guest")
return;
if (spamLink.test(message)) {
uhub.log("banning " + from.nick + " (" + from.cid + ") for link spam");
from.ban(86400, "Link spam in main chat"); // 24 hours
return uhub.DENY;
}
});
Pass 0 (or any value <= 0) for a permanent ban. Bans only survive a
restart if a storage plug-in such as
mod_auth_sqlite is loaded to persist them.
Several scripts together
Each script gets its own JavaScript context — its own globals, its own
uhub.config — while sharing one engine instance. Two scripts cannot see or clobber
each other's variables, and each can be written as if it were the only one.
They are not isolated from each other in effect, though. For an interceptable event
every script runs in load order until one returns a non-DEFAULT verdict; for a
notification event they all run. A typical layout:
/etc/uhub/js.d/
├── 10-welcome.js # notify only, never intercepts
├── 20-nickpolicy.js # denies bad renames
├── 30-antispam.js # denies spammy chat
└── 90-flood.js # last word on flood handling
dir= loads sorted by filename, so a numeric prefix makes the order explicit and
stable when you add a script later.
Safety model
Handlers run inline on the hub's single reactor thread. The host constrains what that can cost:
-
No ambient authority. A script gets the
uhubobject and nothing else. QuickJS'sstdandosmodules are not exposed, so there is no filesystem, network or process access — not even to read its own config file. Everything a script needs from outside comes throughuhub.config. -
A watchdog per callback. Each handler runs under a wall-clock budget
(
time_limit, default one second). A script stuck in a loop is interrupted instead of wedging the hub for every connected user. -
Memory and stack caps.
memory_limitandstack_limitbound the engine; runaway allocation fails inside the script rather than taking the process down. -
File integrity checks. Script files get the same treatment as
.soplug-ins: the hub refuses to load one that is group- or world-writable, or that is not a regular file. -
Expiring user references. A
useris invalidated when its handler returns, so a stashed reference throws rather than dangling — see Object lifetime.
There is no async/await, no timers and no I/O on the hot path.
A handler must return promptly; everything it does happens before the hub can serve
another user. The watchdog is a backstop against bugs, not a budget to spend.
Errors and debugging
An exception thrown during evaluation — a syntax error, a bad top-level statement —
stops the hub from starting, with the error in the log. An exception thrown inside a
handler is caught, logged as
mod_javascript: <file>: uncaught exception: …, and treated as
DEFAULT for that event; the hub keeps running and the next event still reaches
the handler.
uhub.log() writes to the hub log and is the only output channel a script has —
there is no console. For anything non-trivial, log at the decision points:
uhub.onChatMsg(function (from, message) {
if (!rule.test(message))
return;
uhub.log("antispam: denied message from " + from.nick + " (" + from.cid + ")");
return uhub.DENY;
});
Scripts are read once, at startup. Editing a file has no effect until the hub is restarted — there is no reload of a running script.
See also
- mod_javascript — the plug-in's configuration reference.
- Plug-in API — the C API, for what JavaScript does not reach.
- Plug-ins — the bundled modules and how to load them.
doc/javascript.txtanddoc/js/in the source tree.