Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,653 changes: 1,625 additions & 28 deletions Cargo.lock

Large diffs are not rendered by default.

42 changes: 41 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,47 @@ serde_json = "1"
# function* query()` without manual job-queue pumping
# * `macro` — `#[rquickjs::module]` ergonomics for host modules
# * `parallel` — required by `futures` for Send-able runtime handles
rquickjs = { version = "0.11", features = ["loader", "futures", "macro", "parallel"] }
rquickjs = { version = "0.12", features = ["loader", "futures", "macro", "parallel"] }

# LLRT modules (AWS) supply the Node/Web API surface for the plugin
# runtime: node:path, node:fs, URL/URLSearchParams, Buffer, TextEncoder/
# TextDecoder, AbortController/AbortSignal, DOMException, streams, fetch.
# Not published to crates.io; pinned to a rev of the llrt monorepo — keep
# every crate on the SAME rev (they share types across workspace-internal
# deps). llrt_timers is deliberately absent: its process-global timer table
# is keyed by raw runtime pointer and never deregistered, which leaks and
# aliases under our one-runtime-per-plugin + reload model.
#
# llrt_abort: its AbortSignal.timeout backend is forced to `sleep-timers`
# by feature unification (llrt_fetch depends on it with defaults), which
# routes into llrt_timers' unsound global table — the host replaces the
# static with a setTimeout-based impl at install (sdk/js/abort_timeout_patch.js).
# llrt_fetch: `compression-rust` swaps brotli/gzip/deflate to Rust impls;
# zstd has no pure-Rust impl and stays a C dep either way. `tls-ring`
# matches the rustls provider reqwest already links so the process has one
# CryptoProvider.
llrt_abort = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_buffer = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
# Gated behind the `subprocess` capability. child_process builds its stdio
# on llrt_stream's Rust stream constructors directly (no node:stream needed)
# and uses the Buffer global; process reads the daemon's real environment.
llrt_child_process = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_crypto = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_exceptions = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_fetch = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05", default-features = false, features = ["http1", "http2", "compression-rust", "webpki-roots", "tls-ring"] }
llrt_fs = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_intl = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_os = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_path = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_process = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_stream_web = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_string_decoder = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_temporal = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_url = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
llrt_util = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05" }
# compression-rust (not the default compression-c): keep the tree C-free,
# sharing llrt_fetch's backend choice.
llrt_zlib = { git = "https://github.com/awslabs/llrt", rev = "5af6d48ee7258cb222d7b3081276d297606afc05", default-features = false, features = ["compression-rust"] }

# Single-threaded tokio drives the AsyncRuntime per query.
tokio = { version = "1", features = ["rt", "macros", "time", "sync", "process", "io-util"] }
Expand Down
31 changes: 20 additions & 11 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ see [plugin-authoring.md](./plugin-authoring.md) and
|---|---|---|
| Language | Rust (edition 2024) | Native binary, no GC, mature ecosystem for the pieces we need |
| UI toolkit | Slint | Declarative `.slint` files with runtime-bound properties (good fit for theme tokens); winit windowing |
| Plugin runtime | rquickjs (QuickJS binding) | ~1 MB embed, pure ECMAScript (not Node), per-context memory caps, interrupt hooks for timeouts |
| Plugin runtime | rquickjs (QuickJS binding) + llrt module crates | ~1 MB embed, per-context memory caps, interrupt hooks for timeouts; the llrt crates supply the Node/Web API surface (`fetch`, `node:path`, `node:fs`, `URL`, web-streams, …) on top of bare QuickJS |
| Persistence | SQLite via `rusqlite` | Frecency + query history; schemas versioned with `rusqlite_migration`. Boring, reliable. |
| Global hotkey | `global-hotkey` (macOS); CLI `--open` (Linux) | Wayland has no portable global-hotkey API; punt to the WM. |

Expand All @@ -30,7 +30,7 @@ fire-and-forget housekeeping.
run here; other threads route through `slint::invoke_from_event_loop`.
- **`highbeam-plugin-runtime`**: single-threaded tokio runtime that
owns every loaded `LoadedPlugin` (rquickjs `AsyncContext`). Plugins
live here because rquickjs futures are `!Send` across `async_with!`
live here because rquickjs futures are `!Send` across `async_with`
under the `parallel` feature, so they can't cross threads.
- **`highbeam-ipc`**: blocks on the unix socket; hops back to the
Slint thread to show the window.
Expand All @@ -49,12 +49,17 @@ write paths.
- On every keystroke (post-debounce), the host calls `cancel.cancel()`
on the previous dispatch token, which both fires JS-side
`AbortSignal` listeners and sets the rquickjs interrupt flag.
- All host APIs that do I/O (`http.get`, `fs.readDir`, `system.exec`,
etc.) accept an optional `signal` and honor it.
- I/O honors `AbortSignal`: `fetch` takes `init.signal`, the host APIs
(`fs.readDir`, `system.exec`, etc.) take an optional `signal`. The host
also injects a 30 s default timeout into every `fetch` via an
`AbortSignal`, which an explicit caller signal still overrides on abort.
- CPU-bound plugins that block synchronously can't be cancelled
gracefully. The per-plugin `timeoutMs` is the hard kill via the
rquickjs interrupt hook. The watchdog runs on the blocking thread
pool so a `while(true){}` JS body can't starve it.
- `setTimeout` is a host polyfill, not llrt's `llrt_timers`: that crate
keys its timer state on a process-global runtime pointer, which is unsound
under high-beam's one-runtime-per-plugin model.

## Frecency

Expand Down Expand Up @@ -204,12 +209,16 @@ Considered seriously. Rejected because:
a load-bearing reason, the DX cost is too high.
- Polyglot plugins are a feature we don't need.

## Why not Node compat
## Why not full Node compat

Considered. Rejected because once `import 'fs'` works, plugin authors
reach for npm and we're back to the bundle problem that motivated
rejecting Electron. The whole point of single-file plugins is to make
that impossible.
Full npm-style Node compat was rejected: once arbitrary `require`/npm
resolution works, plugin authors reach for the registry and we're back to
the bundle problem that motivated rejecting Electron. The whole point of
single-file plugins is to make that impossible.

Plugins import from `highbeam:*` modules only. The host loader
rejects every other specifier.
The loader allowlist is `highbeam:*` plus a curated set of bare `node:*`
built-ins (`node:path`, `node:fs`, `node:fs/promises` today, all backed by
llrt crates). Bare specifiers (`import 'fs'`, `import 'lodash'`) and
unsupported `node:*` names are rejected. There is no `node_modules`
resolution, so a `node:*` import can only ever resolve to a built-in the
host vetted, never to an npm package.
65 changes: 49 additions & 16 deletions docs/plugin-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,39 +136,65 @@ type Result = {
};
```

Plugins can `import` only from the `highbeam:*` scheme. `import 'fs'`,
`import 'lodash'`, etc. are rejected at load time.
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` | `highbeam:http.get` / `.post` |
| `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:platform`, and `highbeam:settings` are uncapped
— no declaration required. See [sdk-reference.md](./sdk-reference.md) for
per-function behavior, signatures, and examples.
`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](./sdk-reference.md) for per-function behavior, signatures,
and examples.

## Runtime globals

The runtime is bare `QuickJS` plus exactly these host-installed globals — no
other Web APIs exist (`fetch`, `URLSearchParams`, `crypto`, …):
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.
- `AbortController` / `AbortSignal` — the signal passed to `query()` is one.
- `TextEncoder` / `TextDecoder` — UTF-8 only. Constructing `TextDecoder`
with any other encoding throws `RangeError`; invalid byte sequences
decode to U+FFFD. The main consumer is `fs.readCache`, which returns a
`Uint8Array`.
- `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](./sdk-reference.md#nodepath)).

## Console + logging

Expand Down Expand Up @@ -197,6 +223,7 @@ Hand-written `.d.ts` files live in `sdk/highbeam/`. A minimal
"moduleResolution": "node",
"strict": true,
"outDir": ".",
"types": ["node"],
"paths": {
"highbeam:*": ["./node_modules/@high-beam/sdk/*"]
}
Expand All @@ -206,8 +233,14 @@ Hand-written `.d.ts` files live in `sdk/highbeam/`. A minimal
```

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

Expand Down Expand Up @@ -322,4 +355,4 @@ as a starting point:
- `dnd` — bundled `5eSpells.json`, `match.fuzzy` ranking, `openUrl`.
- `app-launcher` — `fs.readDir`, `icons.forPath`, `match.fuzzy`,
cross-platform Spotlight equivalent.
- `xkcd` — HTTP + `fs.cache`-backed title index, fuzzy search.
- `xkcd` — `fetch` + `fs.cache`-backed title index, fuzzy search.
Loading
Loading