Finding Modules
Search Metro for Discord code to read or change.
Find the Discord module your plugin needs by what it exports, its name or its file path. Module numbers change with every Discord update, so never use them.
Quick Start
Pass two or three property names only that module has. findByProps matches the module that has all of them.
import { metro } from '@unbound-app/api';
const Messages = metro.findByProps('sendMessage', 'receiveMessage');Choosing a Lookup
Use the first one that works. Each is cheaper or better typed than the next.
- A namespace that already holds it.
metro.common.Themeandmetro.api.Messagesneed no search. See Modules Unbound Already Found. flux.getStorefor a store. It is typed.metro.stores.Usersis the same store without types. See Flux Stores.- A
findBy*helper. It uses the cache and suggests Discord's names as you type. - A custom filter, when no helper fits.
| Helper | Matches the module whose | Good for |
|---|---|---|
findByProps('a', 'b') | Exports include every name | Utility modules, action creators |
findByName('Name') | Default export has this name | Components, classes |
findByFilePath('path/to/file') | Source file path contains this | Modules with no distinctive exports |
findByPrototypes('method') | Default export's prototype has every method | Classes |
A file path is a good key for a component. Renaming an export does not change it. findByFilePath('modules/chat_input/native/FloatingChatInputContainer.tsx') finds the one module there.
Reference
Every function is on metro, from @unbound-app/api. find and each findBy* helper are lazy by default.
| Function | Does |
|---|---|
findByProps(...props, options?) | Finds the module exposing every name in props |
findByName(name, options?) | Finds the module whose default export is named name |
findByFilePath(path, options?) | Finds the module whose source path contains path |
findByPrototypes(...methods, options?) | Finds the module whose default export's prototype has every method |
find(filter, options?) | Finds the module matching a filter |
findLazy(filter, options?) | Promise of the matching module, resolved once Discord loads it. Never loads it |
bulk(...items) | Runs several searches in one pass. Returns one result per item |
filter(predicate) | Defines a reusable filter that takes arguments |
onModuleLoad(listener) | Calls listener with every module as it loads. Returns a remover |
onModuleLoaded(filter, onLoad, options?) | Calls onLoad once with the matching module when it loads, or options.onMissing when it is known missing |
getLoadedModule(handle) | The module behind a lazy handle, or undefined if not loaded. Never loads it |
getModulePresence(handle) | 'loaded', 'present', 'missing' or 'unknown', without loading |
metro.filters builds filters: byProps, byName, byDisplayName, byTypeName, byFilePath, byPrototypes, byFluxStore, all, any and not.
Full signatures: metro and metro.filters in the SDK reference.
Options
Every search takes these as its last argument.
| Option | Default | Does |
|---|---|---|
lazy | true | Returns a stand-in and searches on first read. false searches now |
all | false | Returns every match as an array. Searches at once |
esModules | true | Also tests each module's default export |
interop | true | Returns the default export when it matched |
cache | true | Tries modules earlier searches found before scanning |
cacheOnly | false | Searches only cached modules |
initialize | true | Runs unloaded modules so they can be searched. false skips them |
raw | false | Returns the Metro module record instead of its exports |
Typed Results
findByProps, findByName and findByFilePath suggest Discord's names as you type, and accept any string.
import { metro } from '@unbound-app/api';
// Typed: the design module's components, with their props. `undefined` when Discord moved it.
const Design = metro.findByProps('TableRow', 'TableRowGroup');
// Not typed yet: an open object with `sendMessage` and `receiveMessage`.
const Messages = metro.findByProps('sendMessage', 'receiveMessage');| Case | Result type |
|---|---|
| Export the types don't list | Still readable, as any |
| Component props | All optional. Unknown props allowed. Only simple types (string, boolean, callbacks) are checked |
| Several typed modules match | Their union. Add a name only yours has |
| Nothing typed matches | An object with the names you passed |
Types describe one Discord build. bun run generate-discord-types refreshes them into packages/discord-types/src/metro.gen.d.ts.
Searches Are Lazy
Each helper returns a stand-in at once and searches on the first read. This keeps launch fast and makes top-level lookups safe. Results are cached.
import { flux } from '@unbound-app/api';
// No search yet: UserStore is a stand-in.
const UserStore = flux.getStore('User');
export function currentUsername() {
// The first read runs the search. Later reads reuse the result.
return UserStore.getCurrentUser()?.username;
}{ lazy: false } searches right away. Use it only inside a function; the Project Rules lint reports it at the top of a file.
These load the module, so avoid them at the top of a file or in start:
| Mistake | Instead |
|---|---|
Reading a property, such as Messages.sendMessage | Read it later, in a handler |
Passing the stand-in to a function that reads it, such as patcher.react.after(Component, callback) | Pass module and key: patcher.react.after(Module, 'default', callback) |
Searching with { all: true } | onModuleLoad for later modules, find(filter, { all: true, initialize: false }) for loaded ones |
A lazy lookup returns undefined when a full search of this Discord build already found the module missing; otherwise it returns the lazy handle. Check it at the top of the feature, after its setting check, and log one warning naming the feature and what moved:
import { metro, patcher } from '@unbound-app/api';
import { settings } from '@unbound-app/settings';
declare function addFavorites(): void;
export function patchEmojiPicker() {
if (!settings.emojiPicker) {
return;
}
const EmojiPicker = metro.findByProps('EmojiPicker');
if (!EmojiPicker) {
logger.warn('Emoji picker skipped: Discord moved EmojiPicker.');
return;
}
patcher.after(EmojiPicker, 'render', addFavorites);
}To act only once a module loads, without loading it, pass a filter to onModuleLoaded. Its onMissing option runs instead when the module is known missing:
import { metro, patcher } from '@unbound-app/api';
declare function addFavorites(): void;
metro.onModuleLoaded(
metro.filters.byProps('EmojiPicker'),
(module) => patcher.after(module, 'render', addFavorites),
{ onMissing: () => logger.warn('Emoji picker skipped: Discord moved EmojiPicker.') },
);The plugin's scope ends a wait that has not settled when it stops. getModulePresence(handle) reports what is known of a handle without loading it.
Custom Filters
Combine filters with metro.filters.all, any and not. A part can be a helper or your own function.
import { metro } from '@unbound-app/api';
const { all, byProps } = metro.filters;
// Lazy, like findByProps.
const Tokens = metro.find(all(byProps('colors'), (m) => typeof m.unsafe_rawColors === 'object'));import { metro } from '@unbound-app/api';
// Throws on the device: any() cannot take a raw filter such as byFilePath.
export const Broken = metro.find(
metro.filters.any(
metro.filters.byFilePath('modules/chat_input/native/FloatingChatInputContainer.tsx'),
metro.filters.byName('FloatingChatInputContainer'),
),
);
// Works: one lookup per key. Use the file path alone when the path is stable.
export const Container = metro.findByFilePath(
'modules/chat_input/native/FloatingChatInputContainer.tsx',
);To reuse a filter with different values, define it with metro.filter. Parameters after the module are its arguments.
import { metro } from '@unbound-app/api';
const byRoute = metro.filter((m, route: string) => m.route === route);
const Settings = metro.find(byRoute('SETTINGS'));Caching
The build hashes each filter's code, ignoring names, quotes and formatting, and gives it a shared cache key. Same code shares an entry across plugins; changed code gets a new one.
- Arguments must be JSON values: strings, numbers, booleans,
null, arrays, plain objects. - A filter may read only its parameters and built-in globals such as
Object. Reading an outside variable fails the build. - The build traces a filter through
consts, imports and re-exports. One it can't trace (passed through a parameter, chosen with? :) searches uncached, with a build warning.
import { metro } from '@unbound-app/api';
const byRoute = metro.filter((m, route: string) => m.route === route);
const wanted = 'SETTINGS';
// Fails the build: the filter reads `wanted`.
metro.find((m) => m.route === wanted);
// Works: `wanted` is an argument.
metro.find(byRoute(wanted));Rewrites
The build turns common shapes into helpers, so they share the client's cache. ubd build --verbose lists each rewrite.
| You write | The build uses |
|---|---|
m.name === 'X' | byName('X') |
m.displayName === 'X' | byDisplayName('X') |
m.type?.name === 'X' | byTypeName('X') |
m.a !== undefined && m.b !== undefined | byProps('a', 'b') |
m.prototype?.render !== undefined | byPrototypes('render') |
m._dispatcher && m.getName?.() === 'UserStore' | byFluxStore('UserStore', false) |
&& or || of these | all(…) or any(…) |
It rewrites only when the match is identical. m.a && m.b stays as written, since it also rejects a 0 or '' export; use byProps('a', 'b') if that never happens.
Waiting for a Module
A screen Discord hasn't opened may not be loaded when your plugin starts. A patch on a lazy result already waits for it. When you need the module itself, use findLazy; it never loads the module early.
import { metro } from '@unbound-app/api';
export async function startMessageFeature() {
const Messages = await metro.findLazy(metro.filters.byProps('sendMessage', 'receiveMessage'));
logger.log('Message actions loaded', Messages);
}Modules Unbound Already Found
import { Users } from '@unbound-app/api/metro/stores';
const me = Users.getCurrentUser();| Namespace | Holds |
|---|---|
metro.api | Action modules: Messages, Linking, Profiles, AsyncUsers, Invites, OAuth2, ActionSheets, UserProfiles |
metro.common | Shared modules: i18n, Moment, REST, Theme, Clipboard, Constants, Reanimated and others |
metro.stores | Accessibility, Guilds, Theme, Users |
metro.components | Discord's components, such as Discord, Forms and Media |
SDK reference: metro.api, metro.common, metro.stores, metro.components.
Exploring Modules
With the phone connected to the Debugger, search from your terminal. It lists each match's id, load state and exports.
bunx @unbound-app/cli debug modules sendMessageTry a search in the debugger's REPL before writing it:
» unbound.metro.findByProps('sendMessage', 'receiveMessage', { lazy: false })