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.

  1. A namespace that already holds it. metro.common.Theme and metro.api.Messages need no search. See Modules Unbound Already Found.
  2. flux.getStore for a store. It is typed. metro.stores.Users is the same store without types. See Flux Stores.
  3. A findBy* helper. It uses the cache and suggests Discord's names as you type.
  4. A custom filter, when no helper fits.
HelperMatches the module whoseGood for
findByProps('a', 'b')Exports include every nameUtility modules, action creators
findByName('Name')Default export has this nameComponents, classes
findByFilePath('path/to/file')Source file path contains thisModules with no distinctive exports
findByPrototypes('method')Default export's prototype has every methodClasses

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.

FunctionDoes
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.

OptionDefaultDoes
lazytrueReturns a stand-in and searches on first read. false searches now
allfalseReturns every match as an array. Searches at once
esModulestrueAlso tests each module's default export
interoptrueReturns the default export when it matched
cachetrueTries modules earlier searches found before scanning
cacheOnlyfalseSearches only cached modules
initializetrueRuns unloaded modules so they can be searched. false skips them
rawfalseReturns 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');
CaseResult type
Export the types don't listStill readable, as any
Component propsAll optional. Unknown props allowed. Only simple types (string, boolean, callbacks) are checked
Several typed modules matchTheir union. Add a name only yours has
Nothing typed matchesAn 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:

MistakeInstead
Reading a property, such as Messages.sendMessageRead 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 writeThe build uses
m.name === 'X'byName('X')
m.displayName === 'X'byDisplayName('X')
m.type?.name === 'X'byTypeName('X')
m.a !== undefined && m.b !== undefinedbyProps('a', 'b')
m.prototype?.render !== undefinedbyPrototypes('render')
m._dispatcher && m.getName?.() === 'UserStore'byFluxStore('UserStore', false)
&& or || of theseall(…) 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();
NamespaceHolds
metro.apiAction modules: Messages, Linking, Profiles, AsyncUsers, Invites, OAuth2, ActionSheets, UserProfiles
metro.commonShared modules: i18n, Moment, REST, Theme, Clipboard, Constants, Reanimated and others
metro.storesAccessibility, Guilds, Theme, Users
metro.componentsDiscord'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 sendMessage

Try a search in the debugger's REPL before writing it:

» unbound.metro.findByProps('sendMessage', 'receiveMessage', { lazy: false })

Next