diff --git a/.nx/version-plans/version-plan-1766951301102.md b/.nx/version-plans/version-plan-1766951301102.md new file mode 100644 index 00000000..2cb5f76e --- /dev/null +++ b/.nx/version-plans/version-plan-1766951301102.md @@ -0,0 +1,5 @@ +--- +"@rozenite/plugin-bridge": minor +--- + +Introduced `createRozeniteRPCBridge` for symmetrical bi-directional RPC communication between App and DevTools. This enables type-safe, contract-first method calls across the bridge. \ No newline at end of file diff --git a/packages/plugin-bridge/README.md b/packages/plugin-bridge/README.md index 78b9a30e..ebf7a1f8 100644 --- a/packages/plugin-bridge/README.md +++ b/packages/plugin-bridge/README.md @@ -101,3 +101,99 @@ Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_camp [prs-welcome]: https://github.com/callstackincubator/rozenite/blob/main/CONTRIBUTING.md [chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge [chat]: https://discord.gg/xgGt7KAjxv + +## RPC Bridge + +The `createRozeniteRPCBridge` function allows you to set up a symmetrical bi-directional RPC bridge between the App and the DevTools. + +### Usage + +1. **Define Protocols** + +```typescript +// Shared types +export type AppProtocol = { + getAppVersion(): Promise; + logMessage(msg: string): Promise; +}; + +export type DevToolsProtocol = { + refresh(): Promise; + showNotification(text: string): Promise; +}; +``` + +2. **Initialize in App** + +```typescript +import { createRozeniteRPCBridge, getRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { AppProtocol, DevToolsProtocol } from './protocols'; + +async function setupAppBridge() { + const client = await getRozeniteDevToolsClient('my-plugin'); + + // Implement local handlers + const localHandlers: AppProtocol = { + async getAppVersion() { + return '1.0.0'; + }, + async logMessage(msg) { + console.log('App Log:', msg); + } + }; + + // Create Bridge + const devTools = createRozeniteRPCBridge( + { + send: (msg) => client.send('rpc', msg), + onMessage: (listener) => client.onMessage('rpc', listener), + }, + localHandlers, + { timeout: 30000 } // Optional: Timeout in ms (default: 60000) + ); + + // Call remote method + await devTools.showNotification('App connected!'); +} +``` + +3. **Initialize in DevTools** + +```typescript +import { useEffect } from 'react'; +import { createRozeniteRPCBridge, useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { AppProtocol, DevToolsProtocol } from './protocols'; + +function MyPlugin() { + const client = useRozeniteDevToolsClient({ pluginId: 'my-plugin' }); + + useEffect(() => { + if (!client) return; + + const localHandlers: DevToolsProtocol = { + async refresh() { + console.log('Refreshing...'); + }, + async showNotification(text) { + alert(text); + return true; + } + }; + + const app = createRozeniteRPCBridge( + { + send: (msg) => client.send('rpc', msg), + onMessage: (listener) => client.onMessage('rpc', listener), + }, + localHandlers + ); + + // Call remote method + app.getAppVersion().then(version => console.log('App Version:', version)); + + }, [client]); + + return
My Plugin
; +} +``` + diff --git a/packages/plugin-bridge/src/index.ts b/packages/plugin-bridge/src/index.ts index b4a094d6..29d38be3 100644 --- a/packages/plugin-bridge/src/index.ts +++ b/packages/plugin-bridge/src/index.ts @@ -4,3 +4,6 @@ export type { Subscription } from './types'; export type { UseRozeniteDevToolsClientOptions } from './useRozeniteDevToolsClient'; export { getRozeniteDevToolsClient } from './client'; export { UnsupportedPlatformError } from './errors'; +export { createRozeniteRPCBridge } from './rpc'; +export type { Transport as RozeniteRPCTransport } from './rpc'; +export type { RPCBridgeOptions } from './rpc'; diff --git a/packages/plugin-bridge/src/rpc.test.ts b/packages/plugin-bridge/src/rpc.test.ts new file mode 100644 index 00000000..43ba6a49 --- /dev/null +++ b/packages/plugin-bridge/src/rpc.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRozeniteRPCBridge } from './rpc'; +import type { RozeniteRPCTransport } from './index'; + +describe('createRozeniteRPCBridge', () => { + const createMockTransport = () => { + let listener: (message: unknown) => void = () => { + // Empty listener to avoid warnings + }; + + return { + send: vi.fn(), + onMessage: vi.fn((l) => { + listener = l; + }), + // Helper to simulate receiving a message + emit: (message: unknown) => listener(message), + }; + }; + + type Local = { + add(a: number, b: number): Promise; + getError(): Promise; + }; + + type Remote = { + multiply(a: number, b: number): Promise; + }; + + it('should call remote methods via proxy and receive results', async () => { + const transport = createMockTransport(); + const localHandlers: Local = { + add: async (a, b) => a + b, + getError: async () => { + throw new Error('Local error'); + }, + }; + + const bridge = createRozeniteRPCBridge( + transport as unknown as RozeniteRPCTransport, + localHandlers + ); + + // Call remote method + const promise = bridge.multiply(2, 3); + + // Verify request was sent + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + method: 'multiply', + params: [2, 3], + id: expect.any(String), + }) + ); + + // Simulate response + const lastCall = transport.send.mock.calls[0][0] as any; + transport.emit({ + jsonrpc: '2.0', + id: lastCall.id, + result: 6, + }); + + const result = await promise; + expect(result).toBe(6); + }); + + it('should handle incoming requests and send responses', async () => { + const transport = createMockTransport(); + const localHandlers: Local = { + add: async (a, b) => a + b, + getError: async () => { + throw new Error('Local error'); + }, + }; + + createRozeniteRPCBridge( + transport as unknown as RozeniteRPCTransport, + localHandlers + ); + + // Simulate incoming request + transport.emit({ + jsonrpc: '2.0', + id: '123', + method: 'add', + params: [10, 20], + }); + + // Wait for promise resolution in handler + await vi.waitFor(() => { + expect(transport.send).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: '123', + result: 30, + }); + }); + }); + + it('should handle errors across the bridge', async () => { + const transport = createMockTransport(); + const localHandlers: Local = { + add: async (a, b) => a + b, + getError: async () => { + throw new Error('Remote crash'); + }, + }; + + const bridge = createRozeniteRPCBridge( + transport as unknown as RozeniteRPCTransport, + localHandlers + ); + + // Test remote error (received from other side) + const promise = bridge.multiply(5, 5); + const lastCall = transport.send.mock.calls[0][0] as any; + + transport.emit({ + jsonrpc: '2.0', + id: lastCall.id, + error: { message: 'Method failed' }, + }); + + await expect(promise).rejects.toThrow('Method failed'); + + // Test local error (sent to other side) + transport.emit({ + jsonrpc: '2.0', + id: '456', + method: 'getError', + params: [], + }); + + await vi.waitFor(() => { + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: '456', + error: expect.objectContaining({ + message: 'Remote crash', + }), + }) + ); + }); + }); + + it('should ignore non-RPC messages', async () => { + const transport = createMockTransport(); + const localHandlers = { + ping: vi.fn(), + }; + + createRozeniteRPCBridge( + transport as unknown as RozeniteRPCTransport, + localHandlers + ); + + // Send invalid message + transport.emit({ type: 'not-rpc', data: {} }); + + expect(localHandlers.ping).not.toHaveBeenCalled(); + expect(transport.send).not.toHaveBeenCalled(); + }); + + it('should timeout if no response is received', async () => { + const transport = createMockTransport(); + const localHandlers = {}; + + vi.useFakeTimers(); + + const bridge = createRozeniteRPCBridge( + transport as unknown as RozeniteRPCTransport, + localHandlers, + { timeout: 1000 } + ); + + const promise = bridge.multiply(1, 2); + + // Fast-forward time + vi.advanceTimersByTime(1001); + + await expect(promise).rejects.toThrow('RPC Timeout: Request multiply timed out after 1000ms'); + + vi.useRealTimers(); + }); +}); diff --git a/packages/plugin-bridge/src/rpc.ts b/packages/plugin-bridge/src/rpc.ts new file mode 100644 index 00000000..093227f6 --- /dev/null +++ b/packages/plugin-bridge/src/rpc.ts @@ -0,0 +1,235 @@ +import { Subscription } from './types'; + +export type Transport = { + send(message: unknown): void; + onMessage(listener: (message: unknown) => void): Subscription | void; +}; + +export type RPCRequest = { + jsonrpc: '2.0'; + id: string; + method: string; + params: unknown[]; +}; + +export type RPCResponseSuccess = { + jsonrpc: '2.0'; + id: string; + result: unknown; +}; + +export type RPCResponseError = { + jsonrpc: '2.0'; + id: string; + error: { + message: string; + code?: number; + data?: unknown; + }; +}; + +export type RPCResponse = RPCResponseSuccess | RPCResponseError; + +export type RPCBridgeOptions = { + /** + * Timeout in milliseconds for RPC requests. + * If a response is not received within this time, the promise will be rejected. + * Set to 0 to disable timeout. + * @default 60000 (60 seconds) + */ + timeout?: number; +}; + +type PendingPromise = { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +}; + +// Error serialization helper +const serializeError = (error: unknown) => { + if (error instanceof Error) { + return { + message: error.message, + data: { + stack: error.stack, + name: error.name, + // Copy other properties + ...Object.getOwnPropertyNames(error).reduce((acc, key) => { + acc[key] = (error as any)[key]; + return acc; + }, {} as Record), + }, + }; + } + return { + message: String(error), + }; +}; + +// Simple ID generator +const generateId = (): string => { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +}; + +const isRPCRequest = (message: unknown): message is RPCRequest => { + const msg = message as any; + return ( + typeof msg === 'object' && + msg !== null && + msg.jsonrpc === '2.0' && + typeof msg.method === 'string' && + typeof msg.id === 'string' && + Array.isArray(msg.params) + ); +}; + +const isRPCResponse = (message: unknown): message is RPCResponse => { + const msg = message as any; + return ( + typeof msg === 'object' && + msg !== null && + msg.jsonrpc === '2.0' && + typeof msg.id === 'string' && + ('result' in msg || 'error' in msg) + ); +}; + +/** + * Creates a Symmetrical Bi-Directional RPC Bridge. + * + * @param transport - The transport layer to send/receive messages. + * @param localHandlers - The implementation of the local methods exposed to the other side. + * @param options - Configuration options. + * @returns A Proxy object representing the remote interface. + */ +export const createRozeniteRPCBridge = < + LocalHandlers extends object, + RemoteInterface extends object +>( + transport: Transport, + localHandlers: LocalHandlers, + options?: RPCBridgeOptions +): RemoteInterface => { + const pendingPromises = new Map(); + const timeoutMs = options?.timeout ?? 60000; + + // Message Handler + transport.onMessage((message: unknown) => { + if (isRPCRequest(message)) { + // It's a request: Execute local handler + const { id, method, params } = message; + const handler = (localHandlers as any)[method]; + + if (typeof handler === 'function') { + try { + const result = handler(...params); + // Handle sync and async results + Promise.resolve(result) + .then((res) => { + const response: RPCResponseSuccess = { + jsonrpc: '2.0', + id, + result: res, + }; + transport.send(response); + }) + .catch((err) => { + const response: RPCResponseError = { + jsonrpc: '2.0', + id, + error: serializeError(err), + }; + transport.send(response); + }); + } catch (err) { + const response: RPCResponseError = { + jsonrpc: '2.0', + id, + error: serializeError(err), + }; + transport.send(response); + } + } else { + // Method not found + const response: RPCResponseError = { + jsonrpc: '2.0', + id, + error: { message: `Method ${method} not found` }, + }; + transport.send(response); + } + } else if (isRPCResponse(message)) { + // It's a response: Resolve/Reject pending promise + const { id } = message; + const pending = pendingPromises.get(id); + if (pending) { + pendingPromises.delete(id); + if ('error' in message && message.error) { + // Reconstruct error + const errData = (message as RPCResponseError).error; + const error = new Error(errData.message); + if (errData.data && typeof errData.data === 'object') { + Object.assign(error, errData.data); + } + pending.reject(error); + } else { + pending.resolve((message as RPCResponseSuccess).result); + } + } + } + // Ignore other messages (opt-in approach) + }); + + // Proxy Mechanism + return new Proxy({} as RemoteInterface, { + get: (target, prop) => { + if (typeof prop === 'string') { + // Return a function that sends the RPC request + return (...args: unknown[]) => { + return new Promise((resolve, reject) => { + const id = generateId(); + + let timer: ReturnType | undefined; + + if (timeoutMs > 0) { + timer = setTimeout(() => { + if (pendingPromises.has(id)) { + pendingPromises.delete(id); + reject( + new Error( + `RPC Timeout: Request ${prop} timed out after ${timeoutMs}ms` + ) + ); + } + }, timeoutMs); + } + + pendingPromises.set(id, { + resolve: (val) => { + if (timer) clearTimeout(timer); + resolve(val); + }, + reject: (err) => { + if (timer) clearTimeout(timer); + reject(err); + }, + }); + + const request: RPCRequest = { + jsonrpc: '2.0', + id, + method: prop, + params: args, + }; + + transport.send(request); + }); + }; + } + return Reflect.get(target, prop); + }, + }); +}; diff --git a/packages/plugin-bridge/vite.config.ts b/packages/plugin-bridge/vite.config.ts index 2dea06f9..32ed412a 100644 --- a/packages/plugin-bridge/vite.config.ts +++ b/packages/plugin-bridge/vite.config.ts @@ -12,6 +12,10 @@ export default defineConfig({ root: __dirname, cacheDir: '../../node_modules/.vite/communication', base: './', + test: { + globals: true, + environment: 'node', + }, build: { lib: { entry: resolve(__dirname, 'src/index.ts'), diff --git a/website/src/docs/plugin-development/plugin-development.md b/website/src/docs/plugin-development/plugin-development.md index 38b24c79..4c62e9eb 100644 --- a/website/src/docs/plugin-development/plugin-development.md +++ b/website/src/docs/plugin-development/plugin-development.md @@ -127,7 +127,7 @@ import React, { useEffect, useState } from 'react'; import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; // Define type-safe event map -interface PluginEvents { +type PluginEvents = { 'user-data': { id: string; name: string; @@ -136,7 +136,7 @@ interface PluginEvents { 'request-user-data': { type: 'userInfo'; }; -} +}; export default function MyPanel() { const client = useRozeniteDevToolsClient({ @@ -185,6 +185,105 @@ export default function MyPanel() { } ``` +### RPC Bridge + +For more complex interactions, Rozenite provides a symmetrical bi-directional RPC bridge. This allows you to call methods on the other side as if they were local asynchronous functions. + +#### 1. Define Protocols + +First, define the interfaces for both the App and DevTools sides. These should be shared between your App and DevTools code. + +```typescript +// protocols.ts +export type AppProtocol = { + getAppVersion(): Promise; + logMessage(msg: string): Promise; +}; + +export type DevToolsProtocol = { + refresh(): Promise; + showNotification(text: string): Promise; +}; +``` + +#### 2. Initialize in App (React Native) + +In your `react-native.ts`, set up the bridge and implement the `AppProtocol`. + +```typescript title="react-native.ts" +import { createRozeniteRPCBridge, DevToolsPluginClient } from '@rozenite/plugin-bridge'; +import { AppProtocol, DevToolsProtocol } from './protocols'; + +export default function setupPlugin(client: DevToolsPluginClient) { + // Implement local handlers for AppProtocol + const localHandlers: AppProtocol = { + async getAppVersion() { + return '1.0.0'; + }, + async logMessage(msg) { + console.log('App Log:', msg); + } + }; + + // Create Bridge + const devTools = createRozeniteRPCBridge( + { + send: (msg) => client.send('rpc', msg), + onMessage: (listener) => client.onMessage('rpc', listener), + }, + localHandlers, + { timeout: 30000 } // Optional: Timeout in ms (default: 60000) + ); + + // You can now call methods on the DevTools side + // devTools.showNotification('App connected!'); +} +``` + +#### 3. Initialize in DevTools (Panel) + +In your panel component, set up the bridge and implement the `DevToolsProtocol`. + +```typescript title="src/my-panel.tsx" +import React, { useEffect } from 'react'; +import { createRozeniteRPCBridge, useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { AppProtocol, DevToolsProtocol } from './protocols'; + +export default function MyPanel() { + const client = useRozeniteDevToolsClient({ pluginId: 'my-plugin' }); + + useEffect(() => { + if (!client) return; + + // Implement local handlers for DevToolsProtocol + const localHandlers: DevToolsProtocol = { + async refresh() { + console.log('Refreshing...'); + }, + async showNotification(text) { + alert(text); + return true; + } + }; + + // Create Bridge + const app = createRozeniteRPCBridge( + { + send: (msg) => client.send('rpc', msg), + onMessage: (listener) => client.onMessage('rpc', listener), + }, + localHandlers + ); + + // Call remote method on App + app.getAppVersion().then(version => console.log('App Version:', version)); + + }, [client]); + + return
My Panel
; +} +``` + ## Step 4: React Native Integration Add React Native functionality by creating a `react-native.ts` file. You can use React Native APIs and libraries to enhance your plugin: @@ -194,7 +293,7 @@ import { DevToolsPluginClient } from '@rozenite/plugin-bridge'; import { Platform, Dimensions } from 'react-native'; // Use the same type-safe event map -interface PluginEvents { +type PluginEvents = { 'user-data': { id: string; name: string; @@ -203,7 +302,7 @@ interface PluginEvents { 'request-user-data': { type: 'userInfo'; }; -} +}; export default function setupPlugin( client: DevToolsPluginClient