orbit/docs/PLUGINS.md

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.

  1. Core UI — the chat, composer, sidebar, settings. This is the app, not a plugin.
  2. 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).
  3. 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