feat(plugins): init new bunny spec

This commit is contained in:
amsyarasyiq 2024-06-18 02:19:31 +08:00
parent dc4d3a18c8
commit b3c1b748a0
14 changed files with 209 additions and 109 deletions

View file

@ -1,6 +1,6 @@
import { Strings } from "@core/i18n";
import { ApplicationCommand, ApplicationCommandOptionType } from "@lib/api/commands/types";
import { plugins as pluginStorage } from "@lib/managers/plugins";
import { BunnyPlugin, sourceToObject as pluginStorage } from "@lib/managers/plugins";
import { messageUtil } from "@metro/common";
export default () => <ApplicationCommand>{
@ -15,7 +15,8 @@ export default () => <ApplicationCommand>{
}
],
execute([ephemeral], ctx) {
const plugins = Object.values(pluginStorage).sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
const plugins = Object.values(pluginStorage).filter(Boolean) as unknown as BunnyPlugin[];
plugins.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
const enabled = plugins.filter(p => p.enabled).map(p => p.manifest.name);
const disabled = plugins.filter(p => !p.enabled).map(p => p.manifest.name);

View file

@ -3,9 +3,9 @@ import { requireAssetIndex } from "@lib/api/assets";
import { isThemeSupported } from "@lib/api/native/loader";
import { after } from "@lib/api/patcher";
import { useProxy } from "@lib/api/storage";
import { installPlugin, plugins, removePlugin } from "@lib/managers/plugins";
import { installPlugin, sourceToObject, removePlugin } from "@lib/managers/plugins";
import { installTheme, removeTheme, themes } from "@lib/managers/themes";
import { DISCORD_SERVER_ID, HTTP_REGEX_MULTI, PLUGINS_CHANNEL_ID, PROXY_PREFIX, THEMES_CHANNEL_ID } from "@lib/utils/constants";
import { DISCORD_SERVER_ID, HTTP_REGEX_MULTI, PLUGINS_CHANNEL_ID, THEMES_CHANNEL_ID, VD_PROXY_PREFIX } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy";
import { Button } from "@metro/common/components";
import { findByProps, findByPropsProxy } from "@metro/utils";
@ -21,8 +21,8 @@ const forumReactions = findByPropsProxy("MostCommonForumPostReaction");
const postMap = {
Plugin: {
storage: plugins,
urlsFilter: (url: string) => url.startsWith(PROXY_PREFIX),
storage: sourceToObject,
urlsFilter: (url: string) => url.startsWith(VD_PROXY_PREFIX),
installOrRemove: (url: string) => {
const isInstalled = postMap.Plugin.storage[url];
return isInstalled ? removePlugin(url) : installPlugin(url);
@ -63,7 +63,7 @@ function useExtractThreadContent(thread: any, _firstMessage = null, actionSheet
function useInstaller(thread: any, firstMessage = null, actionSheet = false): [true] | [false, PostType, boolean, boolean, () => Promise<void>] {
const [postType, url] = useExtractThreadContent(thread, firstMessage, actionSheet) ?? [];
useProxy(plugins);
useProxy(sourceToObject);
useProxy(themes);
const [isInstalling, setIsInstalling] = React.useState(false);

View file

@ -4,7 +4,7 @@ import { isThemeSupported } from "@lib/api/native/loader";
import { after, instead } from "@lib/api/patcher";
import { installPlugin } from "@lib/managers/plugins";
import { installTheme } from "@lib/managers/themes";
import { PROXY_PREFIX, THEMES_CHANNEL_ID } from "@lib/utils/constants";
import { THEMES_CHANNEL_ID, VD_PROXY_PREFIX } from "@lib/utils/constants";
import { lazyDestructure } from "@lib/utils/lazy";
import { channels, url } from "@metro/common";
import { byMutableProp } from "@metro/filters";
@ -20,7 +20,7 @@ const { getChannelId } = lazyDestructure(() => channels);
const { getChannel } = lazyDestructure(() => findByProps("getChannel"));
function typeFromUrl(url: string) {
if (url.startsWith(PROXY_PREFIX)) {
if (url.startsWith(VD_PROXY_PREFIX)) {
return "plugin";
} else if (url.endsWith(".json") && isThemeSupported()) {
return "theme";

View file

@ -170,13 +170,13 @@ export const initVendettaObject = (): any => {
rawColors: color.rawColors
},
plugins: {
plugins: plugins.plugins,
fetchPlugin: (id: string) => plugins.fetchPlugin(id),
installPlugin: (id: string, enabled?: boolean | undefined) => plugins.installPlugin(id, enabled),
startPlugin: (id: string) => plugins.startPlugin(id),
stopPlugin: (id: string, disable?: boolean | undefined) => plugins.stopPlugin(id, disable),
removePlugin: (id: string) => plugins.removePlugin(id),
getSettings: (id: string) => plugins.getSettings(id)
plugins: plugins.sourceToObject,
fetchPlugin: (source: string) => plugins.fetchPlugin(source),
installPlugin: (source: string, enabled = true) => plugins.installPlugin(source, enabled),
startPlugin: (id: string) => plugins.startPlugin(plugins.sourceToObject[id]!.pluginId),
stopPlugin: (id: string, disable = true) => plugins.stopPlugin(plugins.sourceToObject[id]!.pluginId, disable),
removePlugin: (id: string) => plugins.removePlugin(plugins.sourceToObject[id]!.pluginId),
getSettings: (id: string) => plugins.getSettingsComponent(plugins.sourceToObject[id]!.pluginId)
},
themes: {
themes: themes.themes,
@ -195,7 +195,7 @@ export const initVendettaObject = (): any => {
useProxy: (_storage: any) => storage.useProxy(_storage),
createStorage: (backend: any) => storage.createStorage(backend),
wrapSync: (store: any) => storage.wrapSync(store),
awaitSyncWrapper: (store: any) => storage.awaitSyncWrapper(store),
awaitSyncWrapper: (store: any) => storage.awaitStorage(store),
createMMKVBackend: (store: string) => storage.createMMKVBackend(store),
createFileBackend: (file: string) => {
// Redirect path to vendetta_theme.json

View file

@ -18,7 +18,8 @@ type SearchKeywords = Array<string | ((obj: any & {}) => string)>;
interface AddonPageProps<T> {
title: string;
fetchFunction: (url: string) => Promise<void>;
items: Record<string, T & ({ id: string; } | { name: string; })>;
items: Record<string, any>;
resolveItem?: (value: any) => T | undefined;
safeModeMessage: string;
safeModeExtras?: JSX.Element | JSX.Element[];
card: React.ComponentType<CardWrapper<T>>;
@ -36,7 +37,9 @@ export default function AddonPage<T>({ card: CardComponent, ...props }: AddonPag
const [search, setSearch] = React.useState("");
const items = useMemo(() => {
return Object.values(props.items).filter(i => typeof i === "object");
let values = Object.values(props.items);
if (props.resolveItem) values = values.map(props.resolveItem);
return values.filter(i => i && typeof i === "object");
}, [props.items]);
const data = useMemo(() => {

View file

@ -1,7 +1,7 @@
import { CardWrapper } from "@core/ui/components/AddonCard";
import { requireAssetIndex } from "@lib/api/assets";
import { useProxy } from "@lib/api/storage";
import { BunnyPlugin, getSettings, startPlugin, stopPlugin } from "@lib/managers/plugins";
import { BunnyPlugin, getSettingsComponent, startPlugin, stopPlugin } from "@lib/managers/plugins";
import { showSheet } from "@lib/ui/sheets";
import { lazyDestructure } from "@lib/utils/lazy";
import { findByProps } from "@metro";
@ -39,20 +39,31 @@ function Title() {
const iconName = plugin.manifest.vendetta?.icon;
const icon = iconName && requireAssetIndex(iconName);
return <Text
numberOfLines={1}
variant="heading-lg/semibold"
const textElement = (
<Text
numberOfLines={1}
variant="heading-lg/semibold"
>
{plugin.manifest.name}
</Text>
);
if (!icon) return textElement;
return <View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 4
}}
>
{icon && <>
{/* TODO: Image appears invisible for some devices/installs */}
<Image
style={styles.smallIcon}
source={icon}
/>
{" "}
</>}
{plugin.manifest.name}
</Text>;
<Image
style={styles.smallIcon}
source={icon}
/>
{textElement}
</View>;
}
// TODO: Wrap in a Card-ish component with red bg
@ -99,10 +110,10 @@ export default function PluginCard({ item: plugin }: CardWrapper<BunnyPlugin>) {
size="sm"
variant="secondary"
icon={requireAssetIndex("WrenchIcon")}
disabled={!getSettings(plugin.id)}
disabled={!getSettingsComponent(plugin.id)}
onPress={() => navigation.push("VendettaCustomPage", {
title: plugin.manifest.name,
render: getSettings(plugin.id),
render: getSettingsComponent(plugin.id),
})}
/>
<IconButton

View file

@ -2,7 +2,7 @@ import { Strings } from "@core/i18n";
import AddonPage from "@core/ui/components/AddonPage";
import PluginCard from "@core/ui/settings/pages/Plugins/PluginCard";
import { useProxy } from "@lib/api/storage";
import { BunnyPlugin, installPlugin, plugins } from "@lib/managers/plugins";
import { BunnyPlugin, idToSource, installPlugin, sourceToObject } from "@lib/managers/plugins";
import { settings } from "@lib/settings";
import { Author } from "@lib/utils/types";
@ -17,8 +17,9 @@ export default function Plugins() {
"manifest.description",
p => p.manifest.authors?.map((a: Author) => a.name).join()
]}
items={idToSource}
resolveItem={id => sourceToObject[id]}
fetchFunction={installPlugin}
items={plugins}
safeModeMessage={Strings.SAFE_MODE_NOTICE_PLUGINS}
card={PluginCard}
/>

View file

@ -1,7 +1,7 @@
import { formatString, Strings } from "@core/i18n";
import { requireAssetIndex } from "@lib/api/assets";
import { purgeStorage, useProxy } from "@lib/api/storage";
import { BunnyPlugin, fetchPlugin, getSettings, removePlugin, startPlugin, stopPlugin } from "@lib/managers/plugins";
import { BunnyPlugin, fetchPlugin, getSettingsComponent, removePlugin, startPlugin, stopPlugin } from "@lib/managers/plugins";
import { showConfirmationAlert } from "@lib/ui/alerts";
import { hideSheet } from "@lib/ui/sheets";
import { showToast } from "@lib/ui/toasts";
@ -18,7 +18,7 @@ interface InfoProps {
export default function PluginInfoActionSheet({ plugin, navigation }: InfoProps) {
useProxy(plugin);
const Settings = getSettings(plugin.id);
const Settings = getSettingsComponent(plugin.id);
return <ActionSheet>
<ScrollView>

View file

@ -111,7 +111,11 @@ export function wrapSync<T extends Promise<any>>(store: T): Awaited<T> {
});
}
export const awaitSyncWrapper = (store: any) => new Promise<void>(res => store[syncAwaitSymbol](res));
export function awaitStorage(...stores: any[]) {
return Promise.all(
stores.map(store => new Promise<void>(res => store[syncAwaitSymbol](res)))
);
}
export {
createFileBackend,

View file

@ -1,5 +1,5 @@
import { clearFolder, downloadFile, fileExists, removeFile, writeFile } from "@lib/api/native/fs";
import { awaitSyncWrapper, createMMKVBackend, createStorage, wrapSync } from "@lib/api/storage";
import { awaitStorage, createMMKVBackend, createStorage, wrapSync } from "@lib/api/storage";
import { safeFetch } from "@lib/utils";
type FontMap = Record<string, string>;
@ -107,7 +107,7 @@ export async function removeFont(name: string) {
}
export async function updateFonts() {
await awaitSyncWrapper(fonts);
await awaitStorage(fonts);
await Promise.allSettled(
Object.keys(fonts).map(
name => saveFont(fonts[name], fonts.__selected === name)

View file

@ -1,9 +1,10 @@
import { allSettled } from "@core/polyfills/allSettled";
import { createVdPluginObject } from "@core/polyfills/vendettaObject";
import { awaitSyncWrapper, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@lib/api/storage";
import { awaitStorage, createMMKVBackend, createStorage, purgeStorage, wrapSync } from "@lib/api/storage";
import { settings } from "@lib/settings";
import { safeFetch } from "@lib/utils";
import { BUNNY_PROXY_PREFIX, PROXY_PREFIX } from "@lib/utils/constants";
import { VD_PROXY_PREFIX } from "@lib/utils/constants";
import invariant from "@lib/utils/invariant";
import { proxyLazy } from "@lib/utils/lazy";
import { logger } from "@lib/utils/logger";
import { Author } from "@lib/utils/types";
@ -13,8 +14,7 @@ type EvaledPlugin = {
settings: () => JSX.Element;
};
// See https://github.com/vendetta-mod/polymanifest
export interface PluginManifest {
interface PluginManifest {
name: string;
description: string;
authors: Author[];
@ -24,38 +24,81 @@ export interface PluginManifest {
vendetta?: {
icon?: string;
};
bunny?: {};
}
export interface BunnyPlugin {
id: string;
source: string;
pluginId: string;
manifest: PluginManifest;
enabled: boolean;
update: boolean;
/**
* Message of plugin startup error. Gone after plugin boots successfully
* */
error?: string;
// TODO: Use fs to avoid unnecessary memory usage
js: string;
/**
* @deprecated use `source` (for URL) or `pluginId` (plugin's unique ID)
* */
id: string;
}
export const plugins = wrapSync(createStorage<Record<string, BunnyPlugin>>(createMMKVBackend("VENDETTA_PLUGINS")));
const loadedPlugins: Record<string, EvaledPlugin> = {};
const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
const OLD_BUNNY_PROXY_PREFIX = "https://bunny-mod.github.io/plugins-proxy";
async function pluginFetch(url: string) {
if (url.startsWith(PROXY_PREFIX)) {
url = url.replace(PROXY_PREFIX, BUNNY_PROXY_PREFIX);
const pluginsEnabled = () => !settings.safeMode?.enabled;
const _pluginInstances: Record<string, EvaledPlugin> = {};
export const idToSource = wrapSync(createStorage<Record<string, string | undefined>>(createMMKVBackend("SELECTED_PLUGINS")));
export const sourceToObject = proxyLazy(() => {
type StorageInterface = Record<string, BunnyPlugin | undefined>;
const storagePromise = createStorage<StorageInterface>(createMMKVBackend("VENDETTA_PLUGINS"));
storagePromise.then(st => {
for (const plugin of Object.values(st)) if (plugin) {
plugin.pluginId ??= `${plugin.manifest.name}-${plugin.manifest.authors?.[0]?.name}`.toLowerCase();
plugin.source ??= plugin.id;
}
});
return wrapSync(storagePromise);
});
export function getPluginById(id: string) {
if (!idToSource[id]) {
for (const plugin of Object.values(sourceToObject)) {
if (plugin?.pluginId === id) {
idToSource[id] = plugin.source;
break;
}
}
}
return await safeFetch(url, { cache: "no-store" });
return idToSource[id] ? sourceToObject[idToSource[id]!] : undefined;
}
export async function fetchPlugin(id: string) {
if (!id.endsWith("/")) id += "/";
const existingPlugin = plugins[id];
export async function fetchPlugin(source: string) {
if (!source.endsWith("/")) source += "/";
const existingPlugin = sourceToObject[source];
const fetch = (url: string) => safeFetch(
url
.replace(VD_PROXY_PREFIX, BUNNY_PROXY_PREFIX)
.replace(OLD_BUNNY_PROXY_PREFIX, BUNNY_PROXY_PREFIX),
{ cache: "no-store" }
);
let pluginManifest: PluginManifest;
try {
pluginManifest = await (await pluginFetch(id + "manifest.json")).json();
pluginManifest = await (await fetch(source + "manifest.json")).json();
} catch {
throw new Error(`Failed to fetch manifest for ${id}`);
throw new Error(`Failed to fetch manifest for ${source}`);
}
let pluginJs: string | undefined;
@ -63,35 +106,38 @@ export async function fetchPlugin(id: string) {
if (existingPlugin?.manifest.hash !== pluginManifest.hash) {
try {
// by polymanifest spec, plugins should always specify their main file, but just in case
pluginJs = await (await pluginFetch(id + (pluginManifest.main || "index.js"))).text();
pluginJs = await (await fetch(source + (pluginManifest.main || "index.js"))).text();
} catch { } // Empty catch, checked below
}
if (!pluginJs && !existingPlugin) throw new Error(`Failed to fetch JS for ${id}`);
invariant(pluginJs || existingPlugin, `Failed to fetch JS from ${source}`);
plugins[id] = {
id: id,
return sourceToObject[source] = {
id: source,
source: source,
pluginId: pluginManifest.name,
manifest: pluginManifest,
enabled: existingPlugin?.enabled ?? false,
update: existingPlugin?.update ?? true,
js: pluginJs ?? existingPlugin.js,
js: pluginJs ?? existingPlugin!.js,
error: existingPlugin?.error
};
}
export async function installPlugin(id: string, enabled = true) {
if (!id.endsWith("/")) id += "/";
if (typeof id !== "string" || id in plugins) throw new Error("Plugin already installed");
await fetchPlugin(id);
if (enabled) await startPlugin(id);
export async function installPlugin(source: string, enabled = true) {
if (!source.endsWith("/")) source += "/";
invariant(!(source in sourceToObject), "Source was already installed");
const plugin = await fetchPlugin(source);
if (enabled) await startPlugin(plugin.pluginId);
}
/**
* @internal
*/
export async function evalPlugin(plugin: BunnyPlugin) {
async function evalPlugin(plugin: BunnyPlugin) {
const vdObject = await createVdPluginObject(plugin);
const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.id}?hash=${plugin.manifest.hash}`;
const pluginString = `vendetta=>{return ${plugin.js}}\n//# sourceURL=${plugin.source}?hash=${plugin.manifest.hash}`;
const raw = (0, eval)(pluginString)(vdObject);
const ret = typeof raw === "function" ? raw() : raw;
@ -99,79 +145,91 @@ export async function evalPlugin(plugin: BunnyPlugin) {
}
export async function startPlugin(id: string) {
if (!id.endsWith("/")) id += "/";
const plugin = plugins[id];
if (!plugin) throw new Error("Attempted to start non-existent plugin");
const plugin = getPluginById(id);
invariant(plugin, "Attempted to start non-existent plugin");
try {
if (!settings.safeMode?.enabled) {
if (pluginsEnabled()) {
const pluginRet: EvaledPlugin = await evalPlugin(plugin);
loadedPlugins[id] = pluginRet;
_pluginInstances[id] = pluginRet;
pluginRet.onLoad?.();
}
delete plugin.error;
plugin.enabled = true;
} catch (e) {
logger.error(`Plugin ${plugin.id} errored whilst loading, and will be unloaded`, e);
logger.error(`Plugin ${plugin.source} errored whilst loading, and will be unloaded`, e);
plugin.error = e instanceof Error ? e.stack : String(e);
try {
loadedPlugins[plugin.id]?.onUnload?.();
_pluginInstances[id]?.onUnload?.();
} catch (e2) {
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e2);
logger.error(`Plugin ${plugin.source} errored whilst unloading`, e2);
}
delete loadedPlugins[id];
delete _pluginInstances[id];
plugin.enabled = false;
}
}
export function stopPlugin(id: string, disable = true) {
if (!id.endsWith("/")) id += "/";
const plugin = plugins[id];
const pluginRet = loadedPlugins[id];
if (!plugin) throw new Error("Attempted to stop non-existent plugin");
const plugin = getPluginById(id);
const pluginInstance = _pluginInstances[id];
invariant(plugin, "Attempted to stop non-existent plugin");
if (!settings.safeMode?.enabled) {
if (pluginsEnabled()) {
try {
pluginRet?.onUnload?.();
pluginInstance?.onUnload?.();
} catch (e) {
logger.error(`Plugin ${plugin.id} errored whilst unloading`, e);
logger.error(`Plugin ${plugin.source} errored whilst unloading`, e);
}
delete loadedPlugins[id];
delete _pluginInstances[id];
}
disable && (plugin.enabled = false);
if (disable) plugin.enabled = false;
}
export async function removePlugin(id: string) {
if (!id.endsWith("/")) id += "/";
const plugin = plugins[id];
const plugin = getPluginById(id);
invariant(plugin, "Removing non-existent plugin");
if (plugin.enabled) stopPlugin(id);
delete plugins[id];
await purgeStorage(id);
delete sourceToObject[plugin.source];
await purgeStorage(plugin.source);
}
/**
* @internal
*/
export async function initPlugins() {
await awaitSyncWrapper(settings);
await awaitSyncWrapper(plugins);
const allIds = Object.keys(plugins);
await awaitStorage(sourceToObject, idToSource, settings);
if (!settings.safeMode?.enabled) {
// Loop over any plugin that is enabled, update it if allowed, then start it.
await allSettled(allIds.filter(pl => plugins[pl].enabled).map(async pl => (plugins[pl].update && await fetchPlugin(pl).catch((e: Error) => logger.error(e.message)), await startPlugin(pl))));
// Wait for the above to finish, then update all disabled plugins that are allowed to.
allIds.filter(pl => !plugins[pl].enabled && plugins[pl].update).forEach(pl => fetchPlugin(pl));
// TODO: Optimize this
if (pluginsEnabled()) {
const allIds = [...new Set(Object.values(sourceToObject).map(s => s!.pluginId))];
// Loop over enabled plugins, update and start them
const enabledPlugins = allIds.filter(id => getPluginById(id)?.enabled);
await Promise.all(enabledPlugins.map(async pl => {
if (getPluginById(pl)!.update) {
try {
await fetchPlugin(pl);
} catch (e) {
logger.error(e);
}
}
await startPlugin(pl);
}));
// Loop over disabled plugins that are allowed to update
allIds
.filter(id => !getPluginById(id)?.enabled && getPluginById(id)!.update)
.forEach(id => fetchPlugin(id));
}
return stopAllPlugins;
return () => Object.keys(_pluginInstances).forEach(p => stopPlugin(p, false));
}
const stopAllPlugins = () => Object.keys(loadedPlugins).forEach(p => stopPlugin(p, false));
export const getSettings = (id: string) => loadedPlugins[id]?.settings;
export function getSettingsComponent(id: string) {
return _pluginInstances[id]?.settings;
}

View file

@ -6,7 +6,7 @@ import { allSettled } from "@core/polyfills/allSettled";
import { getStoredTheme, getThemeFilePath } from "@lib/api/native/loader";
import { ThemeManager } from "@lib/api/native/modules";
import { after, before, instead } from "@lib/api/patcher";
import { awaitSyncWrapper, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@lib/api/storage";
import { awaitStorage, createFileBackend, createMMKVBackend, createStorage, wrapSync } from "@lib/api/storage";
import { findInReactTree, safeFetch } from "@lib/utils";
import { lazyDestructure, proxyLazy } from "@lib/utils/lazy";
import { Author } from "@lib/utils/types";
@ -230,7 +230,7 @@ export function getThemeFromLoader(): Theme | null {
}
export async function updateThemes() {
await awaitSyncWrapper(themes);
await awaitStorage(themes);
const currentTheme = getThemeFromLoader();
await allSettled(Object.keys(themes).map(id => fetchTheme(id, currentTheme?.id === id)));
}

View file

@ -1,7 +1,6 @@
export const DISCORD_SERVER = "https://discord.gg/XjYgWXHb9Q";
export const GITHUB = "https://github.com/pyoncord";
export const PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
export const BUNNY_PROXY_PREFIX = "https://bn-plugins.github.io/vd-proxy";
export const VD_PROXY_PREFIX = "https://vd-plugins.github.io/proxy";
export const HTTP_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/;
export const HTTP_REGEX_MULTI = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;

View file

@ -0,0 +1,23 @@
/**
* `invariant` is used to [assert](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions) that the `condition` is [truthy](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy).
* 💥 `invariant` will `throw` an `Error` if the `condition` is [falsey](https://github.com/getify/You-Dont-Know-JS/blob/bdbe570600d4e1107d0b131787903ca1c9ec8140/up%20%26%20going/ch2.md#truthy--falsy)
* 🤏 `message`s are not displayed in production environments to help keep bundles small
*
* ```ts
* const value: Person | null = { name: "Alex" };
* invariant(value, "Expected value to be a person");
* // type of `value`` has been narrowed to `Person`
* ```
*/
export default function invariant(
condition: any,
message?: string | (() => string),
): asserts condition {
if (condition) return;
const resolvedMessage: string | undefined = typeof message === "function" ? message() : message;
const prefix = "[Invariant Violation]";
const value = resolvedMessage ? `${prefix}: ${resolvedMessage}` : prefix;
throw new Error(value);
}