feat(experiment): basic plugin API

This commit is contained in:
Paul Makles 2022-03-27 21:45:54 +01:00
parent f9f0d5c55a
commit b3be822568
3 changed files with 263 additions and 3 deletions

View file

@ -17,6 +17,7 @@ import Layout from "./stores/Layout";
import LocaleOptions from "./stores/LocaleOptions"; import LocaleOptions from "./stores/LocaleOptions";
import MessageQueue from "./stores/MessageQueue"; import MessageQueue from "./stores/MessageQueue";
import NotificationOptions from "./stores/NotificationOptions"; import NotificationOptions from "./stores/NotificationOptions";
import Plugins from "./stores/Plugins";
import ServerConfig from "./stores/ServerConfig"; import ServerConfig from "./stores/ServerConfig";
import Settings from "./stores/Settings"; import Settings from "./stores/Settings";
import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync"; import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync";
@ -39,10 +40,13 @@ export default class State {
queue: MessageQueue; queue: MessageQueue;
settings: Settings; settings: Settings;
sync: Sync; sync: Sync;
plugins: Plugins;
private persistent: [string, Persistent<unknown>][] = []; private persistent: [string, Persistent<unknown>][] = [];
private disabled: Set<string> = new Set(); private disabled: Set<string> = new Set();
client?: Client;
/** /**
* Construct new State. * Construct new State.
*/ */
@ -57,6 +61,7 @@ export default class State {
this.queue = new MessageQueue(); this.queue = new MessageQueue();
this.settings = new Settings(); this.settings = new Settings();
this.sync = new Sync(this); this.sync = new Sync(this);
this.plugins = new Plugins(this);
makeAutoObservable(this); makeAutoObservable(this);
this.register(); this.register();
@ -118,6 +123,11 @@ export default class State {
* @returns Function to dispose of listeners * @returns Function to dispose of listeners
*/ */
registerListeners(client?: Client) { registerListeners(client?: Client) {
if (client) {
this.client = client;
this.plugins.onClient(client);
}
const listeners = this.persistent.map(([id, store]) => { const listeners = this.persistent.map(([id, store]) => {
return reaction( return reaction(
() => stringify(store.toJSON()), () => stringify(store.toJSON()),
@ -191,7 +201,10 @@ export default class State {
); );
}); });
return () => listeners.forEach((x) => x()); return () => {
delete this.client;
listeners.forEach((x) => x());
};
} }
/** /**
@ -228,6 +241,9 @@ export default class State {
// Dump stores back to disk. // Dump stores back to disk.
await this.save(); await this.save();
// Post-hydration, init plugins.
this.plugins.init();
} }
} }

View file

@ -10,12 +10,16 @@ import Store from "../interfaces/Store";
/** /**
* Union type of available experiments. * Union type of available experiments.
*/ */
export type Experiment = "dummy" | "offline_users"; export type Experiment = "dummy" | "offline_users" | "plugins";
/** /**
* Currently active experiments. * Currently active experiments.
*/ */
export const AVAILABLE_EXPERIMENTS: Experiment[] = ["dummy", "offline_users"]; export const AVAILABLE_EXPERIMENTS: Experiment[] = [
"dummy",
"offline_users",
"plugins",
];
/** /**
* Definitions for experiments listed by {@link Experiment}. * Definitions for experiments listed by {@link Experiment}.
@ -32,6 +36,11 @@ export const EXPERIMENTS: {
description: description:
"If you can take the performance hit (for example, you're on desktop), you can re-enable offline users for big servers such as Revolt Lounge.", "If you can take the performance hit (for example, you're on desktop), you can re-enable offline users for big servers such as Revolt Lounge.",
}, },
plugins: {
title: "Experimental Plugin API",
description:
"This will enable the experimental plugin API. Only touch this if you know what you're doing.",
},
}; };
export interface Data { export interface Data {

235
src/mobx/stores/Plugins.ts Normal file
View file

@ -0,0 +1,235 @@
import localforage from "localforage";
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Client } from "revolt.js";
import { mapToRecord } from "../../lib/conversion";
import State from "../State";
import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store";
type Plugin = {
/**
* Plugin Format Revision
*/
format: 1;
/**
* Semver Version String
*/
version: string;
/**
* Plugin Namespace
*
* This will usually be the author's name.
*/
namespace: string;
/**
* Plugin Id
*
* This should be a valid URL slug, i.e. cool-plugin.
*/
id: string;
/**
* Entrypoint
*
* Valid Javascript code, must be function which returns object.
*
* ```typescript
* function (state: State) {
* return {
* onClient: (client: Client) => {},
* onUnload: () => {}
* }
* }
* ```
*/
entrypoint: string;
/**
* Whether this plugin is enabled
*
* @default true
*/
enabled?: boolean;
};
type Instance = {
format: 1;
onClient?: (client: Client) => {};
onUnload?: () => void;
};
// Example plugin:
// state.plugins.add({ format: 1, version: "0.0.1", namespace: "insert", id: "my-plugin", entrypoint: "(state) => { console.log('[my-plugin] Plugin init!'); return { onClient: c => console.log('[my-plugin] Acquired Client:', c, '\\nHello', c.user.username + '!'), onUnload: () => console.log('[my-plugin] bye!') } }" })
export interface Data {
"revite:plugins": Record<string, Plugin>;
}
/**
* Handles loading and persisting plugins.
*/
export default class Plugins implements Store, Persistent<Data> {
private state: State;
private plugins: ObservableMap<string, Plugin>;
private instances: Map<string, Instance>;
/**
* Construct new Draft store.
*/
constructor(state: State) {
this.plugins = new ObservableMap();
this.instances = new Map();
makeAutoObservable(this);
this.state = state;
}
get id() {
return "revite:plugins";
}
toJSON() {
return {
"revite:plugins": mapToRecord(this.plugins),
};
}
@action hydrate(data: Data) {
Object.keys(data["revite:plugins"]).forEach((key) =>
this.plugins.set(key, data["revite:plugins"][key]),
);
}
/**
* Get plugin by id
* @param namespace Namespace
* @param id Plugin Id
*/
@computed get(namespace: string, id: string) {
return this.plugins.get(namespace + "/" + id);
}
/**
* Get an existing instance of a plugin
* @param plugin Plugin Information
* @returns Plugin Instance
*/
private getInstance(plugin: Pick<Plugin, "namespace" | "id">) {
return this.instances.get(plugin.namespace + "/" + plugin.id);
}
/**
* Initialise all plugins
*/
init() {
if (!this.state.experiments.isEnabled("plugins")) return;
this.plugins.forEach(
({ namespace, id, enabled }) => enabled && this.load(namespace, id),
);
}
/**
* Add a plugin
* @param plugin Plugin Manifest
*/
add(plugin: Plugin) {
if (!this.state.experiments.isEnabled("plugins"))
return console.error("Enable plugins in experiments!");
let loaded = this.getInstance(plugin);
if (loaded) {
this.unload(plugin.namespace, plugin.id);
}
this.plugins.set(plugin.namespace + "/" + plugin.id, plugin);
if (typeof plugin.enabled === "undefined" || plugin) {
this.load(plugin.namespace, plugin.id);
}
}
/**
* Remove a plugin
* @param namespace Plugin Namespace
* @param id Plugin Id
*/
remove(namespace: string, id: string) {
this.unload(namespace, id);
this.plugins.delete(namespace + "/" + id);
}
/**
* Load a plugin
* @param namespace Plugin Namespace
* @param id Plugin Id
*/
load(namespace: string, id: string) {
let plugin = this.get(namespace, id);
if (!plugin) throw "Unknown plugin!";
try {
let ns = plugin.namespace + "/" + plugin.id;
let instance: Instance = eval(plugin.entrypoint)();
this.instances.set(ns, {
...instance,
format: plugin.format,
});
this.plugins.set(ns, {
...plugin,
enabled: true,
});
if (this.state.client) {
instance.onClient?.(this.state.client);
}
} catch (error) {
console.error(`Failed to load ${namespace}/${id}!`);
console.error(error);
}
}
/**
* Unload a plugin
* @param namespace Plugin Namespace
* @param id Plugin Id
*/
unload(namespace: string, id: string) {
let plugin = this.get(namespace, id);
if (!plugin) throw "Unknown plugin!";
let ns = plugin.namespace + "/" + plugin.id;
let loaded = this.getInstance(plugin);
if (loaded) {
loaded.onUnload?.();
this.plugins.set(ns, {
...plugin,
enabled: true,
});
}
}
/**
* Reset everything
*/
reset() {
localforage.removeItem("revite:plugins");
window.location.reload();
}
/**
* Push client through to plugins
*/
onClient(client: Client) {
for (const instance of this.instances.values()) {
instance.onClient?.(client);
}
}
}