@aurologic/mist-api
v0.3.1
Published
A library for Evi plugins: typed native calls, native-to-page events, Flux store hooks, a cross-plugin bus, and debugging over CDP
Readme
mist-api
mist-api is a TypeScript library for Evi plugins. It adds to Evi's plugin API in these ways:
- Native calls are typed from the native module itself.
await mist.native.lookup(place)gets its argument and result types fromnative.ts, so a renamed method or a changed argument fails the typecheck. - The native side can send events to the page. Evi's native side can't push, so
mist.events.subscribelong-polls a channel made withnativeEvents, in order, and loads the page's state again after a restart. - Native calls keep their errors and can be cancelled. A
NativeError(code, data)thrown innative.tsreaches the page as aMistNativeErrorwith the samecode, andmist.native.method.with({ signal })aborts the method'sthis.signal. Native code can also ask the page and wait for the answer withnativeRequestsandmist.requests.handle. - The native side has a toolkit:
discordWindow(), alifetime(ctx)scope that kills child processes and clears timers on stop,runandwhich,jsonFilewith atomic writes flushed on stop,secretswith Electron'ssafeStorage, andpathGrants, file dialog handles that keep page input from naming paths. - The plugin's context is held for you.
mist.ctxis the running context andmist.runningsays whether there is one, so a plugin needs no module-levellet contextand nocontext!.mist.signalaborts when the run stops, andmist.toastdoes nothing after it, so timers and promise callbacks need nomist.runningcheck. - Common plugin code comes ready to use. That includes a Flux store hook, typed stores,
fetchwith a timeout,sleep, a bus between plugins, andmist.storage, a key-value store in IndexedDB for each plugin and, if you want, each account. mist.definechecks the plugin's patches. A$selfname in a patch that the definition has no function for is a warning in the log and inmist status, not aTypeErrorinside Discord's code.- Plugins can be debugged over the Chrome DevTools Protocol (CDP). The
mistCLI reads each plugin's logs, native calls, native logs, and exposed values, and calls native methods, while Discord runs with a debugging port. It also prints Flux actions as they are dispatched, reads Flux stores, and shows the layout of an element and the CSS rules that set it. It reads the page's errors by plugin, source-mapped for watch builds, follows hot reloads, shows where plugins spend time, diffs a patched module, and lists the React components above an element.mist manifest fixchecks a manifest's translations and size without Discord. - Plugins can add UI to Discord's.
@aurologic/mist-api/uihas shared registries for chat bar buttons, hover bar buttons, UI under messages, and listeners before a message is sent or edited, each entry inside an error boundary. - Plugins can be unit tested without Discord.
@aurologic/mist-api/testinghas a fake plugin context, a fake Flux store, and a fake@evi/apimodule for any test runner's module mock.
It is a general library for anyone who writes Evi plugins. Evi's build bundles everything a plugin imports except @evi/api, React, and electron, so each plugin carries its own copy of mist-api. State that plugins share, such as the debug registry and the bus, lives on globalThis under versioned Symbol.for keys, so copies of different versions work together.
Install
mist-api goes where Evi's build finds it, in <evi>/userplugins/node_modules. Evi's build only builds folders under userplugins that have a manifest.json, so userplugins/node_modules is never built as a plugin.
From npm
Install the package in <evi>/userplugins. This makes a package.json and a node_modules there if they don't exist yet:
cd <evi>/userplugins
npm install @aurologic/mist-apibun add @aurologic/mist-api works too.
Types for @evi/api
mist-api's types refer to @evi/api, which isn't on npm. It is a peer dependency marked optional, so npm doesn't try to install it. Evi's build provides it when it bundles a plugin, and Evi's tsconfig.json provides its types by mapping @evi/api to Evi's own sources.
A plugin under <evi>/userplugins needs no setup, because Evi's tsconfig.json covers userplugins. A plugin kept in its own repository needs the same mapping. Point its tsconfig.json at an Evi checkout:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"paths": {
"@evi/api": ["<evi>/src/api/index.ts"],
"@evi/api/*": ["<evi>/src/api/*"],
"@shared/*": ["<evi>/src/shared/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", "<evi>/src/shared/globals.d.ts"]
}globals.d.ts declares the globals and the .css imports that Evi's sources use. Without it, the typecheck fails inside Evi's files.
From a local checkout
To work on mist-api and a plugin together, link the checkout into your Evi checkout. The package loads from dist/, so build the checkout first, and again after each change. Develop mist-api lists the commands. On Windows, run this in cmd:
mkdir <evi>\userplugins\node_modules\@aurologic
mklink /J <evi>\userplugins\node_modules\@aurologic\mist-api <path to mist-api>On macOS and Linux:
mkdir -p <evi>/userplugins/node_modules/@aurologic
ln -s <path to mist-api> <evi>/userplugins/node_modules/@aurologic/mist-apiA git dependency such as github:InfiniteCod3/mist-api doesn't work, because dist/ isn't in the repository. Install from npm to pin a version.
For plugin authors
- Install mist-api in
<evi>/userplugins, as Install shows. - Make a folder for the plugin in
<evi>/userpluginswith amanifest.jsonand anindex.tsx. Theidinmanifest.jsonis the id you pass tocreateMist. - In
index.tsx, callcreateMistonce and export the result asmist. Exportmist.define({...})as the default export, in place ofdefinePlugin. - For a native side, write
native.tswithdefineNative. Passtypeof nativetocreateMist, imported withimport type. - Run
bun run devin your Evi checkout and turn the plugin on. - Start Discord with a debugging port with
npx -p @aurologic/mist-api mist restart. Then read the plugin withmist status,mist logs <id>, andmist calls <id>the same way. - For bug reports from users, who can't run the CLI, offer
mist.copyReport()in a settings panel button ormist.reportCommand()as a slash command. mist.report lists what the report contains.
The example below shows each file. Migrate a plugin to mist-api covers a plugin that already exists.
Example
This plugin looks up the current temperature for a place in its native side and logs it. It takes three files in <evi>/userplugins/weather-demo/.
native.ts runs in Discord's main process. defineNative wraps the methods and returns the module:
import { defineNative } from "@aurologic/mist-api/native";
import { fetchJson, HttpError, isRecord } from "@aurologic/mist-api/util";
export interface Place {
latitude: number;
longitude: number;
}
export interface Forecast {
temperature: number;
}
export default defineNative({
async lookup(place: Place): Promise<Forecast | null> {
// The page is untrusted, so check the argument even though it is typed
if (!isRecord(place) || typeof place.latitude !== "number" || typeof place.longitude !== "number") throw new Error("Bad place");
const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", String(place.latitude));
url.searchParams.set("longitude", String(place.longitude));
url.searchParams.set("current", "temperature_2m");
try {
const body = await fetchJson(url, { timeout: 15_000 });
const current = isRecord(body) ? body.current : undefined;
return isRecord(current) && typeof current.temperature_2m === "number" ? { temperature: current.temperature_2m } : null;
} catch (err) {
if (err instanceof HttpError && err.status === 400) return null;
throw err;
}
},
});index.tsx runs in Discord's page. import type brings in the native module's type and none of its code:
import type { SettingsSchema } from "@evi/api";
import { createMist } from "@aurologic/mist-api";
import type native from "./native";
const settings = {
latitude: { type: "number", label: "Latitude", default: 52.52 },
longitude: { type: "number", label: "Longitude", default: 13.41 },
} satisfies SettingsSchema;
export const mist = createMist<typeof settings, typeof native>("weather-demo");
export default mist.define({
settings,
async start(ctx) {
const forecast = await mist.native.lookup({ latitude: ctx.settings.get("latitude"), longitude: ctx.settings.get("longitude") });
mist.log.info(forecast ? `${forecast.temperature} °C` : "no forecast");
mist.expose("forecast", () => forecast);
},
});manifest.json is an ordinary Evi manifest. The id passed to createMist must be the manifest's id, because the CLI finds plugins by that id. A plugin with a native side is full access, and its permissions.network lists the hosts that the native side contacts:
{
"id": "weather-demo",
"name": "Weather Demo",
"description": "Shows the current temperature for a place",
"version": "1.0.0",
"authors": ["you"],
"permissions": { "network": ["api.open-meteo.com"] }
}Run bun run dev in your Evi checkout and turn the plugin on. Evi's plugin guide covers the manifest and the build.
Use Discord's app
@aurologic/mist-api/discord covers the parts of Discord's app that plugins often rebuild: saved accounts and account switches, Discord's own API client with typed errors and message paging, Discord's router, avatar URLs, the clipboard, times and dates in Discord's language, and the message box.
import { accounts, chat, discord, format } from "@aurologic/mist-api/discord";
export default mist.define({
start(ctx) {
ctx.onDispose(accounts.on("connected", ({ id }) => mist.log.info(`connected as ${id}`)));
},
});
// After mist.define, which registers the mist object that discord() checks
const api = discord(mist);
export async function showRecent(channelId: string) {
const lines: string[] = [];
for await (const message of api.messages(channelId, { limit: 200 })) {
lines.push(`[${format.date(message.timestamp, { timeStyle: "short" })}] ${message.author.username}: ${message.content}`);
}
chat.localMessage(`Read ${lines.length} messages.`, channelId);
return lines;
}Reading messages through Discord's API needs "network": ["discord.com"] and "readMessages": true in the manifest's permissions. discord(mist) holds the plugin's requests to its declaration, by the same rules Evi applies to its fetch. It takes only the plugin's own mist, after mist.define, so one plugin can't use another's permissions by naming its id. Discord lists what each part needs.
Add UI to Discord's
@aurologic/mist-api/ui adds a plugin's UI to Discord's. It has buttons in the chat bar and in the hover bar of messages, UI under messages, and listeners that can change or stop a message before it is sent or edited. Plugins that bundle different copies of mist-api share each registry, and each entry goes when its plugin stops. Each entry renders inside an error boundary, so UI that throws shows a small notice and leaves Discord's UI working. The subpath also has state and useValue for state that a plugin's UI shares, and host for a mount point that keeps working across hot reloads.
import { chatBar, ChatBarButton, messages } from "@aurologic/mist-api/ui";
export default mist.define({
patches: [...chatBar.patches, ...messages.patches],
start() {
chatBar.add(mist, "hello", ({ channel }) => (
<ChatBarButton label="Say hello" onClick={() => mist.log.info("hello from", channel.id)}>Hi</ChatBarButton>
));
messages.beforeSend(mist, "no-shouting", draft => {
if (draft.content === draft.content.toUpperCase()) return { cancel: true };
});
},
});The registries need Discord code that mist-api patches, so spread their patches into the plugin's own. They take only the plugin's own mist. The hover bar, the UI under messages, and the send and edit listeners see message content, so they need "readMessages": true in the manifest's permissions, or no permissions at all, as Evi asks of a plugin that hooks sending. UI covers each part.
Debug a plugin over CDP
The mist CLI talks to Discord over its debugging port. It runs on Node 22 or later. From npm, run it with npx -p @aurologic/mist-api mist, which works whether or not it's installed. Name the package with -p: a bare npx mist outside the install folder downloads an unrelated npm package called mist. From a checkout, run the built file, or run the source on Node 22.18 or later or on Bun:
npx -p @aurologic/mist-api mist <command>
node <path to mist-api>/dist/bin/mist.js <command>
bun <path to mist-api>/bin/mist.ts <command>The examples below write mist for any of these.
Restart Discord with a debugging port:
mist restartOn Windows this force-closes
Discord.exeand starts it again through%LOCALAPPDATA%\Discord\Update.exewith--remote-debugging-port=9222. On macOS and Linux it prints the command to run.mist restart --dry-runprints the Windows commands without running them.List the plugins that use mist-api:
mist statusEach line shows whether the plugin runs, its mist-api version, its native side, whether the debug view is open, and its error counts. Under it are the plugin's patch and lookup health from Evi's own diagnostics.
Read and follow a plugin's page log:
mist logs weather-demo -fSee its native calls, with arguments, result or error, and time taken:
mist calls weather-demoSee what its native side logged and was called with. Native
consoleoutput goes to Discord's main process stdout, which CDP can't see, so mist-api keeps it for this command:mist native-logs weather-demoRead the values the plugin exposes, and its settings:
mist values weather-demoCall a native method. Each argument is JSON, or a plain string when it isn't valid JSON:
mist call weather-demo lookup '{"latitude":52.52,"longitude":13.41}'Read Evi's patch and lookup diagnosis, problems first:
mist patches weather-demoWatch the Flux actions that Discord dispatches, masked like recorded values, until Ctrl+C:
mist flux PRESENCE_UPDATES -fList a Flux store's methods, and call one that reads:
mist store SelectedChannelStore mist store SelectedChannelStore getChannelIdSee why an element is laid out the way it is, from its computed layout, its ancestors', and the CSS rules that set them:
mist inspect '[class^="sidebar_"]' --depth 2Read the page's errors, each with the plugin it came from and, for a watch build, its frames in your source files:
mist errors weather-demo -fWatch each hot reload of a dev build: whether it started, and its patch health:
mist dev weather-demoFind slow plugins and patches that never call in, see what patches did to a module, and see which React components render an element:
mist perf mist module 123456 --diff mist react '[class^="sidebar_"]'Before an upload, check the manifest's translations and size, and fix them:
mist manifest fix plugins/weather-demo --write
The CLI shows the page's logs, calls, and values whole for every plugin. mist call works, and mist native-logs shows values whole, only when Discord's main process was started with a debugging port and the plugin's native side is made with defineNative. Other plugins on the page see only each value's type and size, log text and error messages included. Fields named like secrets (token, password, cookie, session, and others) are masked even in the whole view. Security model explains why.
Documentation
- createMist is the page side of a plugin (
@aurologic/mist-api). - defineNative is the main-process side (
@aurologic/mist-api/native). - Events sends events from the native side to the page with
nativeEventsandmist.events.subscribe. - Native toolkit has
discordWindow,lifetime,run,which,dataFolder,jsonFile,secrets, andpathGrants. - Stores covers Discord's Flux stores and
useStore(@aurologic/mist-api/stores). - Util covers
isRecord,sleep,fetchWithTimeout,fetchJson,HttpError, and theDiscordNativetypes (@aurologic/mist-api/util). - Discord covers accounts, Discord's API, navigation, avatars, the clipboard, formatting in Discord's language, and the message box (
@aurologic/mist-api/discord). - Bus covers channels and slots between plugins (
@aurologic/mist-api/bus). - UI covers the error boundary,
state,host, and the chat bar, hover bar, message accessory and message listener registries (@aurologic/mist-api/ui). - Testing covers the test doubles for a plugin's unit tests (
@aurologic/mist-api/testing). - CLI lists every
mistcommand and flag. - Security model explains the debug gate.
- Migrate a plugin to mist-api shows each change with before and after code.
Develop mist-api
To typecheck, build, and test mist-api itself, it needs Evi's types. scripts/setup.ts points .evi at an Evi checkout, and links node_modules/.evi to the same checkout for the build. It uses $EVI_DIR, then a sibling ../evi, and clones BleedDev/evi into .evi when it finds neither.
bun install
bun scripts/setup.ts
bun run typecheck
bun run build
bun test
node scripts/check-pack.ts
bun scripts/check-install.tsbun run build writes dist/. For each subpath it writes an ES module, a .d.ts file, and a source map, and it writes the CLI as dist/bin/mist.js. scripts/check-pack.ts checks what npm pack would publish. It fails if a file that exports or bin names is missing, or if src, tests, scripts, or .evi would ship. scripts/check-install.ts installs the packed tarball into a new project and checks that npx mist runs, that a file importing every subpath typechecks with the tsconfig from Types for @evi/api, and that a Bun bundle of a small plugin carries only what it imports. The CDP test also runs against dist/ once it is built, and is skipped until then.
Security
To report a vulnerability, see SECURITY.md.
License
GPL-3.0-or-later. See LICENSE.
