diff --git a/src/__tests__/services/moduleService.test.ts b/src/__tests__/services/moduleService.test.ts new file mode 100644 index 0000000..66c439e --- /dev/null +++ b/src/__tests__/services/moduleService.test.ts @@ -0,0 +1,94 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Tests for ModuleService — the generic host-side gateway to worklet modules. + * Module-agnostic: it only forwards callModule/lifecycle/events by name. + */ + +import { ModuleService } from '../../services/moduleService' +import { requireInitialized } from '../../utils/storeHelpers' + +jest.mock('../../utils/storeHelpers', () => ({ + requireInitialized: jest.fn(), +})) + +const flushMicrotasks = async (): Promise => { await new Promise((r) => setTimeout(r, 0)) } + +describe('ModuleService', () => { + let mockHRPC: any + + beforeEach(() => { + jest.clearAllMocks() + mockHRPC = { + callModule: jest.fn(), + onModuleEvent: jest.fn(), + } + ;(requireInitialized as jest.Mock).mockResolvedValue(mockHRPC) + }) + + describe('callModule', () => { + it('serializes args, calls callModule, and parses the JSON result', async () => { + mockHRPC.callModule.mockResolvedValue({ result: JSON.stringify({ id: '1', name: 'Alice' }) }) + + const res = await ModuleService.callModule('addressBook', 'addContact', { name: 'Alice' }) + + expect(res).toEqual({ id: '1', name: 'Alice' }) + expect(mockHRPC.callModule).toHaveBeenCalledWith({ + module: 'addressBook', + method: 'addContact', + args: JSON.stringify([{ name: 'Alice' }]), + }) + }) + + it('returns undefined for an empty/void result', async () => { + mockHRPC.callModule.mockResolvedValue({ result: '' }) + expect(await ModuleService.callModule('addressBook', 'noop')).toBeUndefined() + }) + + it('propagates worklet errors to the caller', async () => { + mockHRPC.callModule.mockRejectedValue(new Error('Method not found: addressBook.bogusMethod')) + await expect(ModuleService.callModule('addressBook', 'bogusMethod')).rejects.toThrow('Method not found') + }) + + it('rejects an empty module name', async () => { + await expect(ModuleService.callModule('', 'm')).rejects.toThrow('moduleName must be a non-empty string') + }) + + it('rejects an empty method name', async () => { + await expect(ModuleService.callModule('addressBook', '')).rejects.toThrow('method must be a non-empty string') + }) + }) + + describe('onModuleEvent', () => { + it('fans out worklet events to subscribers and stops after unsubscribe', async () => { + let dispatch: ((evt: { module: string, event: string, payload?: string | null }) => void) | undefined + mockHRPC.onModuleEvent.mockImplementation((cb: typeof dispatch) => { dispatch = cb }) + + const received: unknown[] = [] + const off = ModuleService.onModuleEvent('addressBook', 'update', (p) => received.push(p)) + + // The dispatcher attaches via requireInitialized().then(...) — let it settle. + await flushMicrotasks() + expect(typeof dispatch).toBe('function') + + dispatch!({ module: 'addressBook', event: 'update', payload: JSON.stringify({ n: 1 }) }) + expect(received).toEqual([{ n: 1 }]) + + off() + dispatch!({ module: 'addressBook', event: 'update', payload: JSON.stringify({ n: 2 }) }) + expect(received).toEqual([{ n: 1 }]) // no delivery after unsubscribe + }) + }) +}) diff --git a/src/hooks/useModule.ts b/src/hooks/useModule.ts new file mode 100644 index 0000000..3f557be --- /dev/null +++ b/src/hooks/useModule.ts @@ -0,0 +1,80 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useMemo, useRef } from 'react' +import { ModuleService, type ModuleEventListener } from '../services/moduleService' + +/** + * Typed "live" proxy for a worklet module: each method call is a callModule RPC, + * and `on(event, listener)` subscribes to module events (auto-cleaned on unmount). + */ +export type UseModuleProxy = T & { + on: (event: string, listener: ModuleEventListener) => () => void +} + +/** + * Generic hook for a bundled worklet module by name. The module is constructed at + * WDK init (via config), so the hook just proxies calls + event subscriptions. + * + * @example + * const addressBook = useModule('addressBook') + * useEffect(() => addressBook.on('update', refresh), []) + * const contact = await addressBook.addContact({ name: 'Alice' }) + */ +export function useModule Promise>>( + moduleName: string, +): UseModuleProxy { + const subscriptionsRef = useRef void>>([]) + + useEffect(() => { + const subscriptions = subscriptionsRef + return () => { + for (const unsubscribe of subscriptions.current) { + try { + unsubscribe() + } catch { + // ignore unsubscribe errors on teardown + } + } + subscriptions.current = [] + } + }, [moduleName]) + + return useMemo(() => { + return new Proxy({} as UseModuleProxy, { + get: (_target, prop) => { + // Avoid promise-like checks treating the proxy as a thenable. + if (prop === 'then') { + return undefined + } + + if (prop === 'on') { + return (event: string, listener: ModuleEventListener): (() => void) => { + const unsubscribe = ModuleService.onModuleEvent(moduleName, event, listener) + subscriptionsRef.current.push(unsubscribe) + return unsubscribe + } + } + + if (typeof prop === 'string') { + return async (...args: unknown[]): Promise => { + return await ModuleService.callModule(moduleName, prop, ...args) + } + } + + return undefined + }, + }) + }, [moduleName]) +} diff --git a/src/index.ts b/src/index.ts index 9154fe2..fa8f00c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,4 +60,9 @@ export type { BalanceQueryOptions } from './hooks/useBalance' export type { AccountInfo } from './store/walletStore' -export { validateMnemonic } from './utils/mnemonicUtils' \ No newline at end of file +export { validateMnemonic } from './utils/mnemonicUtils' + +export { useModule } from './hooks/useModule' +export type { UseModuleProxy } from './hooks/useModule' +export { ModuleService } from './services/moduleService' +export type { ModuleEventListener } from './services/moduleService' diff --git a/src/services/moduleService.ts b/src/services/moduleService.ts new file mode 100644 index 0000000..1d4d6e0 --- /dev/null +++ b/src/services/moduleService.ts @@ -0,0 +1,115 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Module Service + * + * Host-side gateway to worklet modules: calls them by name (callModule / events). + */ + +import { handleServiceError } from '../utils/errorHandling' +import { requireInitialized } from '../utils/storeHelpers' +import { safeStringify } from '../utils/jsonUtils' +import { validateModuleName } from '../utils/validation' + +// Module RPC surface, declared locally so rn-core compiles against the current +// published HRPC type until @tetherto/pear-wrk-wdk is bumped. +interface ModuleRpc { + callModule: (req: { module: string, method: string, args?: string }) => Promise<{ result?: string | null }> + onModuleEvent: (cb: (evt: { module: string, event: string, payload?: string | null }) => void) => void +} + +export type ModuleEventListener = (payload: unknown) => void + +// Module-level registry of host-side event listeners, fanned out from the +// single worklet -> host moduleEvent channel. +const listeners = new Map>() +// HRPC instances we've already attached the moduleEvent dispatcher to. +const wiredInstances = new WeakSet() + +function listenerKey (moduleName: string, event: string): string { + return `${moduleName}::${event}` +} + +function ensureEventDispatcher (rpc: ModuleRpc): void { + if (wiredInstances.has(rpc as object)) { + return + } + wiredInstances.add(rpc as object) + rpc.onModuleEvent(({ module, event, payload }) => { + const set = listeners.get(listenerKey(module, event)) + if (set === undefined || set.size === 0) { + return + } + let parsed: unknown = null + if (typeof payload === 'string' && payload.length > 0) { + try { + parsed = JSON.parse(payload) + } catch { + parsed = payload + } + } + for (const cb of set) { + try { + cb(parsed) + } catch { + // Listener errors must not break the dispatcher. + } + } + }) +} + +/** + * Module Service — host-side gateway to worklet modules. + */ +export class ModuleService { + static async callModule (moduleName: string, method: string, ...args: unknown[]): Promise { + validateModuleName(moduleName) + if (typeof method !== 'string' || method.trim().length === 0) { + throw new Error('method must be a non-empty string') + } + const rpc = (await requireInitialized()) as unknown as ModuleRpc + + try { + const response = await rpc.callModule({ module: moduleName, method, args: safeStringify(args) }) + if (response?.result === undefined || response?.result === null || response.result === '') { + return undefined + } + return JSON.parse(response.result) + } catch (error) { + handleServiceError(error, 'ModuleService', `callModule:${moduleName}.${method}`, { moduleName, method }) + } + } + + // Subscribe to a worklet -> host module event; returns an unsubscribe fn. The + // dispatcher attaches once the worklet is ready (idempotent per instance). + static onModuleEvent (moduleName: string, event: string, listener: ModuleEventListener): () => void { + validateModuleName(moduleName) + const key = listenerKey(moduleName, event) + let set = listeners.get(key) + if (set === undefined) { + set = new Set() + listeners.set(key, set) + } + set.add(listener) + + void requireInitialized() + .then((rpc) => { ensureEventDispatcher(rpc as unknown as ModuleRpc) }) + .catch(() => { /* worklet not ready yet; init will attach the dispatcher */ }) + + return () => { + listeners.get(key)?.delete(listener) + } + } +} diff --git a/src/types.ts b/src/types.ts index 68ffee9..e156e26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,9 @@ export interface WdkConfigs< protocols?: { [protocolName: string]: WdkProtocolConfig } + modules?: { + [moduleName: string]: Record + } } /** diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 7a08685..7f2b768 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -127,6 +127,15 @@ export function validateNetworkName(network: string): void { } } +/** + * Validate a module name + */ +export function validateModuleName(moduleName: string): void { + if (typeof moduleName !== 'string' || moduleName.trim().length === 0) { + throw new Error('moduleName must be a non-empty string') + } +} + export function validateAssetId(assetId: string): void { try { assetIdSchema.parse(assetId)