brick-module
Version:
Better React Native native module development
218 lines (186 loc) • 6.84 kB
text/typescript
/**
* Brick Module Framework - Main API
* Type-safe access to native modules with automatic code generation
*/
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
import { BrickError } from "./BrickError";
/**
* Base interface that all Brick module specs must extend
*/
export interface BrickModuleInterface extends TurboModule {
readonly moduleName: string;
}
/**
* Type alias for module specifications
* Use this as the base interface when defining your module specs
* @example
* export interface MyModuleSpec extends BrickModuleSpec {
* readonly moduleName: "MyModule";
* readonly supportedEvents: ["eventA", "eventB"];
* myMethod(param: string): Promise<string>;
* }
*/
export type BrickModuleSpec = BrickModuleInterface;
// Module-level state (previously static class members)
const moduleCache = new Map<string, any>();
let nativeModule: any = null;
/**
* Gets the native TurboModule instance
* @private
*/
function getNativeModule() {
if (!nativeModule) {
nativeModule = TurboModuleRegistry.getEnforcing("BrickModule");
}
return nativeModule;
}
/**
* Enhanced typed module interface with event listeners
*/
export type BrickModuleWithEvents<T extends BrickModuleInterface> = T;
/**
* Gets a typed module instance by name with explicit type parameter
* @param moduleName - The exact name of the module as defined in its spec
* @returns Typed module interface with all methods, constants, and event listeners
*/
function get<T extends BrickModuleInterface>(
moduleName: string
): BrickModuleWithEvents<T> {
// Check cache first
const cacheKey = moduleName;
if (moduleCache.has(cacheKey)) {
return moduleCache.get(cacheKey);
}
const nativeModuleInstance = getNativeModule();
// Cache constants for this module (computed once, reused)
let constantsCache: Record<string, any> | null = null;
// Create a proxy that intercepts method calls and forwards them to the native module
const moduleProxy = new Proxy({} as BrickModuleWithEvents<T>, {
get: (_target, property: string | symbol) => {
if (typeof property !== "string") {
return undefined;
}
// New-style event subscription: onXxx((event) => void) → EventSubscription
if (
property.startsWith("on") &&
property.length > 2 &&
property[2] === property[2].toUpperCase()
) {
return (listener: (event: unknown) => void) => {
// Respect RN CodegenTypes.EventEmitter: call `${moduleName}_onXxx` and return as-is
const nativeEventMethod = `${moduleName}_${property}`;
const nativeFn = (nativeModuleInstance as any)[nativeEventMethod];
if (typeof nativeFn === "function") {
return nativeFn(listener);
}
throw new Error(
`${nativeEventMethod} is not available. Did you run RN codegen?`
);
};
}
// Special handling for getConstants method (with caching)
if (property === "getConstants") {
return () => {
if (constantsCache !== null) {
return constantsCache;
}
const allConstants = nativeModuleInstance?.getConstants?.() ?? {};
const moduleConstants: Record<string, any> = {};
const constantPrefix = `${moduleName}_`;
for (const key in allConstants) {
if (key.startsWith(constantPrefix)) {
const propName = key.substring(constantPrefix.length);
moduleConstants[propName] = allConstants[key];
}
}
constantsCache = moduleConstants;
return moduleConstants;
};
}
// Handle method calls
return (...args: any[]) => {
const methodKey = `${moduleName}_${property}`;
// Try direct method call (generated by codegen)
if (typeof nativeModuleInstance[methodKey] === "function") {
const result = nativeModuleInstance[methodKey](...args);
// Check if result is a wrapped sync method result
// Sync methods have explicit "~sync": true marker for reliable detection
// Format: { "~sync": true, success: true, value: T } | { "~sync": true, success: false, error: {...} }
if (
result &&
typeof result === "object" &&
result["~sync"] === true
) {
if (result.success === true) {
// Success: return the unwrapped value
return result.value;
} else {
// Failure: throw a BrickError with all properties from error object
const errorInfo = result.error || {};
const message = errorInfo.message || errorInfo.errorMessage || "Unknown error";
const code = errorInfo.code || errorInfo.errorCode || "BRICK_ERROR";
// Build userInfo from additional properties
const userInfo: Record<string, unknown> = { code };
for (const key of Object.keys(errorInfo)) {
if (key !== "code" && key !== "message" && key !== "errorCode" && key !== "errorMessage") {
userInfo[key] = errorInfo[key];
}
}
throw new BrickError(message, code, userInfo, moduleName);
}
}
// Async method: wrap Promise and convert to BrickError
if (result && typeof result.then === "function") {
return result.catch((error: unknown) => {
// Convert to BrickError with moduleName for type-safe error handling
throw BrickError.from(error, moduleName);
});
}
// Not wrapped: return as-is
return result;
}
throw new Error(`Method ${methodKey} not found`);
};
},
has: (_target, property) => {
return typeof property === "string";
},
ownKeys: (_target) => {
// Return empty array since we're proxying all property access
return [];
},
});
// Cache the proxy
moduleCache.set(cacheKey, moduleProxy);
return moduleProxy;
}
/**
* Gets list of all registered modules
* @returns Promise resolving to array of module names
*/
function getRegisteredModules(): string[] {
const nativeModuleInstance = getNativeModule();
return nativeModuleInstance?.getRegisteredModules() ?? [];
}
/**
* Clears the module cache (useful for testing or hot reloading)
* @internal
*/
function clearCache(): void {
moduleCache.clear();
}
/**
* Main Brick Module API object
* Provides type-safe access to native modules
*/
export const BrickModule = {
get,
getRegisteredModules,
clearCache,
} as const;
/**
* Default export for convenience
*/
export default BrickModule;
// Re-export types handled by main index.ts