Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/__tests__/services/moduleService.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => { 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
})
})
})
80 changes: 80 additions & 0 deletions src/hooks/useModule.ts
Original file line number Diff line number Diff line change
@@ -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 extends object> = 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<AddressBookApi>('addressBook')
* useEffect(() => addressBook.on('update', refresh), [])
* const contact = await addressBook.addContact({ name: 'Alice' })
*/
export function useModule<T extends object = Record<string, (...args: unknown[]) => Promise<unknown>>>(
moduleName: string,
): UseModuleProxy<T> {
const subscriptionsRef = useRef<Array<() => 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<T>, {
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<unknown> => {
return await ModuleService.callModule(moduleName, prop, ...args)
}
}

return undefined
},
})
}, [moduleName])
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,9 @@ export type { BalanceQueryOptions } from './hooks/useBalance'

export type { AccountInfo } from './store/walletStore'

export { validateMnemonic } from './utils/mnemonicUtils'
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'
115 changes: 115 additions & 0 deletions src/services/moduleService.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<ModuleEventListener>>()
// HRPC instances we've already attached the moduleEvent dispatcher to.
const wiredInstances = new WeakSet<object>()

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<unknown> {
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)
}
}
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export interface WdkConfigs<
protocols?: {
[protocolName: string]: WdkProtocolConfig<TProtocol>
}
modules?: {
[moduleName: string]: Record<string, unknown>
}
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading