Skip to content

Latest commit

 

History

History
395 lines (322 loc) · 15.2 KB

plugin-authoring.md

File metadata and controls

395 lines (322 loc) · 15.2 KB

Plugin authoring

Plugins are single-file JS programs the host loads at startup. No npm, no bundler, no toolchain at runtime — just manifest.json + plugin.js. The host loader resolves highbeam:* specifiers; everything else is rejected.

This page is the table of contents. The detailed material lives in three sibling docs:

Doc Read it when you want to
plugin-tutorial.md Build your first plugin start to finish, with vitest
sdk-reference.md Look up signatures, capabilities, behaviour of highbeam:*
plugin-cookbook.md Find a copy-pasteable recipe for a specific pattern
views.md Return a dynamic, stateful screen instead of a flat result

For the host's internals — query dispatch, threading, cancellation — see internals.md.

At a glance

plugins/
  my-plugin/
    manifest.json          required
    plugin.js              required (or whatever manifest.entry points at)
    plugin.log             written by the host on demand
    [your data files, like spells.json or http.json]
    [optional dev-time files: package.json, vitest.config.ts, *.test.js]
{
    "name": "my-plugin",
    "displayName": "My Plugin",
    "version": "0.1.0",
    "description": "What it does",
    "entry": "plugin.js",
    "debounceMs": 0,
    "timeoutMs": 500,
    "memoryMb": 32,
    "capabilities": ["actions"],
    "platforms": ["macos", "linux"],
    "options": [
        { "key": "github_username", "type": "string", "label": "GitHub username", "default": "" },
        { "key": "result_limit", "type": "int", "label": "Max results", "default": 10, "min": 1, "max": 50 }
    ]
}
import { copy } from 'highbeam:actions';

export async function* query(input, signal) {
    if (!input) return;
    yield {
        key: 'hello',
        title: `hello: ${input}`,
        subtitle: 'press Enter to copy',
        action: copy(input),
    };
}

That's the entire surface area. Everything else is detail.

Where the host looks for plugins

In priority order:

  1. --plugins-dir <path> (test override).
  2. ./plugins/ next to the binary's cwd (dev convenience), if present.
  3. Platform default:
    • macOS: ~/Library/Application Support/high-beam/plugins/
    • Linux: $XDG_DATA_HOME/high-beam/plugins/

Drop a plugin directory into the chosen location and restart High Beam. There is no hot reload in v1.

Manifest cheat sheet

Full schema lives in src/plugins/manifest.rs. Unknown fields are tolerated so new fields can land without breaking older plugins.

  • name — required; unique; lowercase-kebab; frecency key prefix and cache directory name.
  • displayName, version, description — optional metadata. version should be valid semver if you want update to compare it sensibly.
  • entry — optional; defaults to plugin.js.
  • debounceMs — wait this long after the latest keystroke before invoking query(). 0 = no debounce. Capped at 2000.
  • timeoutMs — wall-clock kill for query(). Default 500.
  • memoryMb — QuickJS context memory cap. Default 32.
  • capabilities — array of capability strings. See SDK reference for the full table.
  • platforms — optional array. Absent loads everywhere; ["macos"] shelves it on Linux; [] shelves it entirely.
  • options — optional array of user-editable settings the host renders in the settings UI. Each entry has key, type ("string", "bool", "int", or "enum"), label, default, plus min/max (int) or choices (enum). Plugins read their own values via highbeam:settings — see the SDK reference.
  • entryUrl — optional; install URL for a single-file plugin. The installer downloads just this JS file and writes it to <plugin>/<entry>. The common case for plugins with no sibling data files. Mutually exclusive with archiveUrl.
  • archiveUrl — optional; download URL for a multi-file plugin archive (.tar.gz, .tgz, .tar, or .zip) — for plugins that ship data files alongside the entry script. Mutually exclusive with entryUrl. Declaring one of entryUrl / archiveUrl is what makes a plugin installable via install <url>; with neither it's local-only.
  • manifestUrl — optional; canonical URL hosting this manifest. The update command re-fetches it to learn whether a newer version exists. Missing ⇒ plugin opts out of update checks.

Tuning guidance is in the cookbook.

Query function shape

export async function* query(
    input: string,
    signal: AbortSignal,
): AsyncIterable<Result>;
  • input is the current query string.
  • signal aborts when the user types another keystroke. Check signal.aborted between yields if you produce many rows; propagate it into I/O calls via opts.signal.
  • Yield Result objects, one per row:
type Result = {
    key: string;             // stable per (plugin, conceptual result)
    title: string;
    subtitle?: string;
    icon?: string;           // data:image/<type>;base64,... URI
    weight?: number;         // 0..100; higher ranks first
    pinned?: boolean;        // sort above non-pinned regardless of weight
    action: Action;
};

Plugins can import from the highbeam:* scheme and from the supported node:* built-ins (node:path, node:fs, node:fs/promises, node:os, node:zlib, node:string_decoder, node:child_process). Every other specifier (import 'lodash', import 'node:net', …) is rejected at load time.

Capabilities at a glance

Capability Grants
actions highbeam:actions
http global fetch (+ Headers / Request / Response / FormData)
clipboard.read highbeam:clipboard.read
clipboard.write highbeam:clipboard.write
fs.read highbeam:fs.readDir / .readFile / .readText
fs.cache highbeam:fs.readCache / .writeCache
fs full filesystem: node:fs / node:fs/promises + all highbeam:fs.*
subprocess spawn programs (node:child_process) + the process global
icons highbeam:icons.forPath
system.exec highbeam:system.exec
system.applescript highbeam:system.applescript

highbeam:match, highbeam:settings, node:path, node:os, node:string_decoder, and node:zlib are uncapped — no declaration required. fs is the broad "read and write any file your user can" grant; prefer the scoped fs.read / fs.cache when they suffice. See sdk-reference.md for per-function behavior, signatures, and examples.

Runtime globals

The runtime is QuickJS (via rquickjs) with the llrt module crates layered on top for the Node and Web API surface. Always-on globals, no capability and no import:

  • console.log/info/warn/error/debug — see below.
  • setTimeout — awaitable; clearTimeout / clearInterval are no-ops.
  • URL / URLSearchParams.
  • Buffer, Blob, File.
  • TextEncoder / TextDecoder: multi-encoding (utf-8, utf-16le, utf-16be, windows-1252), with BOM handling; invalid byte sequences decode to U+FFFD.
  • ReadableStream and the rest of the web-streams family.
  • DOMException.
  • AbortController / AbortSignal: the signal passed to query() is one. AbortSignal.timeout(ms), AbortSignal.any([...]), AbortSignal.abort(reason) all work; signal.reason is a DOMException and signal.throwIfAborted() throws it.
  • crypto: WebCrypto (getRandomValues, randomUUID, subtle.*) plus the node-style randomBytes / createHash / createHmac helpers.
  • Temporal: the full Temporal surface (Now, PlainDate, Instant, …).
  • Intl: partial — DateTimeFormat and supportedValuesOf only, no NumberFormat / Collator.

fetch (with Headers / Request / Response / FormData) is gated on the http capability rather than always-on, and the process global is gated on subprocess. The node:path, node:os, node:zlib, node:string_decoder, node:fs, node:fs/promises, and node:child_process modules are imported, not globals (see sdk-reference.md).

Console + logging

console.log/info/warn/error/debug from a plugin are captured to <plugin_dir>/plugin.log, prefixed with timestamp + level. No rotation.

[2026-05-20T15:30:42.123Z] [INFO ] hello world
[2026-05-20T15:30:42.456Z] [ERROR] something broke
    continuation lines indent four spaces

This is also where capability violations, parse errors, timeouts, and memory-cap hits surface.

TypeScript

Hand-written .d.ts files live in sdk/highbeam/. A minimal tsconfig.json for a TS plugin:

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "ES2022",
        "moduleResolution": "node",
        "strict": true,
        "outDir": ".",
        "types": ["node"],
        "paths": {
            "highbeam:*": ["./node_modules/@high-beam/sdk/*"]
        }
    },
    "include": ["plugin.ts"]
}

npm install --save-dev <path-to-this-repo>/sdk/highbeam (or symlink it) to pull the ambient highbeam:* types in. Compiled plugin.js keeps the bare highbeam:* specifiers — TypeScript only needs the types at compile time.

Types for the node:* modules come from @types/node (npm install --save-dev @types/node, hence "types": ["node"] above); the SDK no longer ships an http.d.ts. The fetch / Response / AbortSignal / AbortController shapes are in the SDK's types.d.ts.

plugins/echo-ts has a full working setup.

Testing with vitest

The SDK ships Node-compatible stubs alongside the .d.ts files, so a plugin can be tested in plain vitest. See plugin-tutorial.md for the full setup, and the cookbook recipe for mocking patterns.

Publishing + distribution

Plugins can ship as install-by-URL packages so users don't have to clone or copy directories. A publishable manifest declares manifestUrl (so update can re-fetch it) plus exactly one distribution shape: entryUrl for a single hosted JS file, or archiveUrl for a multi-file archive.

Single file (entryUrl) — the common case

Most plugins are one plugin.js with no sibling data. Host the script and the manifest, then point entryUrl at the script:

  1. Host plugin.js and manifest.json somewhere your users can GET them (a Gist's raw URLs, S3, your own CDN — anything HTTP(S)).

  2. Add entryUrl and manifestUrl to your manifest.json:

    {
      "name": "my-plugin",
      "version": "1.0.0",
      "entryUrl": "https://example.com/my-plugin/plugin.js",
      "manifestUrl": "https://example.com/my-plugin/manifest.json"
    }

The installer downloads entryUrl and writes it to <plugin>/<entry> (entry defaults to plugin.js), then drops the fetched manifest.json alongside it.

Multi-file archive (archiveUrl)

When the plugin ships data files next to its entry script, package the whole directory instead:

  1. Build a .tar.gz (or .zip) of your plugin directory — anything that would land under <user-plugins-dir>/<name>/ after extraction. The archive may wrap its contents in a single top-level directory or unpack directly; the host accepts either layout.

    tar -czf my-plugin.tar.gz -C my-plugin .
    # or, with the wrapping directory:
    tar -czf my-plugin.tar.gz my-plugin
  2. Host the archive somewhere your users can GET it (S3, GitHub Releases, your own CDN — anything HTTP(S)).

  3. Add archiveUrl and manifestUrl to your manifest.json:

    {
      "name": "my-plugin",
      "version": "1.0.0",
      "archiveUrl": "https://example.com/my-plugin/1.0.0/my-plugin.tar.gz",
      "manifestUrl": "https://example.com/my-plugin/manifest.json"
    }

Either way, publish the same manifest.json at manifestUrl — the host re-fetches it on update to discover new versions.

Users install with:

install https://example.com/my-plugin/manifest.json

…and update everything with update. Both commands stream progress in the launcher result list.

What the installer enforces

  • The fetched manifest must have name, version, and exactly one of entryUrl / archiveUrl. A missing pair, or both at once, aborts the install.
  • If the archive bundles its own manifest.json, the installer cross-checks it against the URL-fetched manifest:
    • version must match exactly (rejected on mismatch).
    • If the embedded manifest carries manifestUrl, it must equal the URL the user installed from (rejected on mismatch).
  • If the archive does not bundle a manifest.json, the URL-fetched one is written into the destination dir with manifestUrl backfilled to the install URL.
  • Pre-existing directories at <user-plugins-dir>/<name>/ are removed before the new version is moved into place. There is no on-disk backup — reinstall from the manifest URL to roll back.

Versioning

version strings should be valid semver — the update command treats them as such. Non-semver strings always compare as "no update due" (conservatism: we never replace a working plugin with an unparseable version label).

Archive formats

  • .tar.gz / .tgz
  • .tar
  • .zip (DEFLATE only — bzip2 / zstd backends were dropped to keep the binary lean)

Format detection looks at the URL extension first, falls back to the HTTP Content-Type header.

Failure handling

  • query() throws → host logs to plugin.log, drops yielded results from this query, renders nothing.
  • Exceeded timeoutMs → host interrupts the context, logs a WARN, drops results.
  • Exceeded memoryMb → host logs an ERROR, drops results.
  • Capability violation at load time → host logs an ERROR, plugin doesn't load.
  • Auto-disable on repeated failures is post-v1; every query gets a fresh try.

Example plugins

Every plugin in plugins/ is real and tested. Copy any of them as a starting point:

  • echo — minimal copy(input).
  • echo-ts — TypeScript variant with tsconfig.json.
  • slow-echo — streaming + abort demo.
  • frecency-demo — equal-weight rows to demonstrate pick-bumping.
  • calculator — pinned, inline math evaluator (npm-free shunting-yard).
  • http-codes — uses highbeam:fs.readText to load bundled http.json, opens MDN.
  • paper-size — inlined data, copy action.
  • dnd — bundled 5eSpells.json, match.fuzzy ranking, openUrl.
  • app-launcher — fs.readDir, icons.forPath, match.fuzzy, cross-platform Spotlight equivalent.
  • xkcd — fetch + fs.cache-backed title index, fuzzy search.