9.9 KiB
Plugins
Orbit has a small, operator-controlled plugin system. A deployment lists
plugin scripts in config.json; each is loaded at startup and registers against
a global Orbit object to hook events, read state, send IRC, theme the UI, and
add UI in predefined slots.
Status: experimental. Orbit is a work in progress and this API may change between releases. Plugins are deployment-controlled (same trust level as the app itself) — there is no user-uploaded plugin marketplace, by design.
Which kind of plugin (the trust rule)
Every plugin runs in each visitor's browser, so the security question isn't "do I trust this code" but "whose data pays if I'm wrong" — and the answer is your users, who never chose the plugin. A plugin with full page access can read a visitor's session and login credential, act as them, and read their DMs. That gives one hard rule, and three buckets:
Untrusted code is always sandboxed. No exceptions.
- Core UI — the chat, composer, sidebar, settings. This is the app, not a plugin.
- Built-in extras — first-party features you wrote and review (radio, clock,
dice, copy, games). Trusted, so isolation is a choice, made by fit:
- a self-contained widget (its own panel or button, reaching IRC through the API) → sandbox it: it costs nothing and doubles as a reference plugin that can't crash the app (radio, clock, dice — see PLUGIN-SDK.md);
- something woven into the UI (a control on every message, a filter over the message stream, or a feature that needs the app's own URL) → run it in-page, with the full React API below; sandboxing it fights the model for no gain (copy, games).
- Third-party plugins — anything you didn't write and audit yourself. Always sandboxed, however trivial it looks. This is the bucket the rule is for.
Same split a lot of servers use: essential, deeply-integrated behaviour is built into the core, optional self-contained bits are separate modules. Orbit does the same, with one twist — a web client can be handed genuinely untrusted plugins, so its module tier (the sandbox) is a real security boundary, not just packaging.
Where plugins live
Plugin files live under public/plugins/third/ (served at /app/plugins/third/).
Per the trust rule above, third-party plugins are always sandboxed — an
opaque-origin iframe reachable only through the capability bridge (see
SANDBOX.md; author them with the SDK). In-page
loading (full page access, no isolation) stays available, but only for trusted
first-party code — never for anything you didn't write yourself.
Enabling plugins
Add script URLs to plugins in config.json. Prefer the sandboxed
object form with an explicit permission list:
{ "plugins": [{ "url": "/app/plugins/third/orbit-clock.js", "sandbox": true, "permissions": [] }] }
A bare URL (or an object without "sandbox": true) still loads in-page — trusted,
full access — for first-party plugins that need the React API:
{ "plugins": ["/app/plugins/third/orbit-demo.js"] }
They load in order, after the app boots. Host them anywhere the page can reach (same-origin recommended). For a third-party origin, pin the file with Subresource Integrity by giving an object instead of a URL:
{ "plugins": [{ "url": "https://cdn.example/x.js", "integrity": "sha384-…" }] }
(crossorigin defaults to anonymous when an integrity hash is set.) See
SECURITY.md for generating the hash and a sample CSP header.
Writing a plugin
A plugin is a plain .js file that calls Orbit.plugin():
Orbit.plugin('my-plugin', (orbit, log) => {
log('loaded, Orbit v' + orbit.version);
orbit.on('message', (m) => log(m.from, 'said', m.text, 'in', m.target));
orbit.addUi('composer_button', () =>
orbit.html`<button class="composer__emoji" title="Shrug"
onClick=${() => orbit.irc.say('¯\\_(ツ)_/¯')}>🤷</button>`);
});
UI is authored with the html tagged template (HTM,
bound to the app's React) — runtime template markup, no build step. Prefer
orbit.h(...) (React.createElement) if you'd rather not use templates.
The Orbit API
Global
| Member | Description |
|---|---|
Orbit.version / Orbit.commit |
app version + git commit (build-time) |
Orbit.apiVersion |
plugin API contract version (bumped on surface changes) — feature-detect with if (Orbit.apiVersion >= N) … |
Orbit.plugin(name, fn) |
register a plugin; fn(orbit, log) |
Orbit.on/once/off/emit(event, …) |
the app event bus |
Orbit.config() |
the resolved runtime config |
Orbit.React / Orbit.ReactDOM / Orbit.jsxRuntime / Orbit.Fragment / Orbit.h / Orbit.html |
render primitives (externalization targets for compiled plugins) |
Inside Orbit.plugin(name, (orbit) => …)
| Member | Description |
|---|---|
orbit.on/once/off/emit |
event bus (see events below) |
orbit.state.active() |
active buffer name |
orbit.state.nick() / account() |
your nick / logged-in account |
orbit.state.buffers() |
open buffer names |
orbit.state.get() |
full store snapshot (read-only) |
orbit.server.hasCap(cap) |
is an IRCv3 capability negotiated? — gate cap-dependent features so you never 421 a leaner server |
orbit.server.caps() |
every cap Orbit negotiates, each {name, available, enabled} |
orbit.server.isupport() |
read-only snapshot of the ISUPPORT (005) tokens |
orbit.server.network() |
network name (ISUPPORT NETWORK) |
orbit.server.numeric(code) |
RPL/ERR name for a numeric reply, e.g. '433' → 'ERR_NICKNAMEINUSE' |
orbit.irc.send(line) |
send a raw IRC line |
orbit.irc.msg(target, text) |
PRIVMSG a target |
orbit.irc.say(text) |
send to the active buffer |
orbit.irc.join(chan) / part(chan) |
join / part |
orbit.irc.list() |
request the channel list |
orbit.themes.current()/list()/set(id) |
read/set the theme |
orbit.storage.get(key, def)/set(key, val) |
namespaced persistence |
orbit.addUi(slot, render) |
add UI to a slot (returns a remover) |
orbit.addSettingsSection({label, icon?, render}) |
add a whole Settings section |
orbit.addMessageDecorator(m => …) |
inline UI after every message's text; m = {id, nick, text, raw, kind, ts, mine} (text is formatting-stripped, raw keeps mIRC codes) |
orbit.addMessageAction(m => …) |
a button in every message's hover action toolbar (next to reply/react); same m |
orbit.h / orbit.html |
render helpers |
log(…) |
namespaced console logger |
Events
ready, connected ({nick}), status (connection status string),
buffer.active (buffer name), message ({from, target, text, self}),
raw (the parsed IrcMessage).
UI slots
| Slot | Where |
|---|---|
composer_button |
a button in the message composer toolbar |
topbar_item |
an item in the channel topbar action row (next to search / notifications) |
sidebar_item |
an item in the conversation sidebar header (next to the compose button) |
navbar |
a full-width bar across the very top of the app (network branding + portal links) |
settings_section |
a whole section in Settings (own nav entry + pane) — use orbit.addSettingsSection() |
Two per-message hooks (added by callback, not slot name) run for every rendered
message and receive a read-only view of it: orbit.addMessageDecorator(m => …)
appends inline UI after the text (badges/chips), while
orbit.addMessageAction(m => …) adds a button to the hover action toolbar next
to reply/react (it inherits the toolbar styling). Every contributed slot, action
and decorator renders inside its own error boundary, so a crashing plugin renders
nothing instead of taking down the app.
Compiled plugins (write real React)
The example above is an uncompiled .js plugin. For anything substantial,
build a plugin like a normal project and compile it to one droppable file — a
compiled, externalized-React plugin model.
The trick: mark react, react-dom and react/jsx-runtime external and map
them to Orbit.React / Orbit.ReactDOM / Orbit.jsxRuntime, so your bundle
shares Orbit's single React instance and never carries its own. (Bundling your
own React breaks hooks with "invalid hook call".) Then author normal TSX with
hooks/state and render it into a slot:
import { useState } from 'react';
Orbit.plugin('my-plugin', (orbit) => {
orbit.addSettingsSection({ label: 'My plugin', icon: '🧩', render: () => <Panel orbit={orbit} /> });
});
A ready-to-copy starter (Vite config with the externals already set up, tsconfig,
ambient types and an example) lives in plugin-template/.
npm install && npm run build → one dist/*.js you drop in and list in
config.json.
Intentionally not exposed
Orbit does not offer access to internal modules or runtime component replacement. Those would couple plugins to internals that are still moving; the API above is the deliberately stable surface. Ask (or open an issue) if you need a hook that isn't here.
Working examples
In-page (trusted, full React API):
| File | Shows |
|---|---|
orbit-demo.js |
events, a composer_button, an IRC action |
orbit-copy.js |
a message_action (toolbar copy button with copied-confirmation) |
orbit-navbar.js |
a brandable top navbar (logo + portal links), configured under "navbar" in config.json |
Sandboxed (the SDK, no page access):
| File | Shows |
|---|---|
orbit-hello.js |
a starter: panel, a slash command, a shortcut |
orbit-radio.js |
a full panel player (hero, volume, station list) |
orbit-clock.js |
a minimal topbar_item clock |