diff --git a/packages/core/src/ThreadedRuntime.tsx b/packages/core/src/ThreadedRuntime.tsx index d4641b9..cf1b916 100644 --- a/packages/core/src/ThreadedRuntime.tsx +++ b/packages/core/src/ThreadedRuntime.tsx @@ -11,6 +11,7 @@ import { type ViewStyle, } from 'react-native'; import NativeThreadedRuntimeSurface from './NativeThreadedRuntimeSurface'; +import { assertJson, type JsonValue } from './json'; const DEFAULT_RUNTIME_NAME = 'background-list'; const DEFAULT_BUSINESS_RUNTIME_NAME = 'business-runtime'; @@ -31,13 +32,17 @@ export type ThreadedComponent> = type ThreadedComponentLoader = () => ComponentType; type ThreadedComponentRegistry = Map; type RuntimeFunctionLoader = () => RuntimeFunction; -export type ThreadedHeadlessTaskContext = { - payload: Payload; +export type ThreadedHeadlessTaskContext< + TPayload extends JsonValue | void = void, +> = { + payload: TPayload; runtimeName: ThreadedRuntimeName; taskName: string; }; -export type ThreadedHeadlessTask = ( - context: ThreadedHeadlessTaskContext, +export type ThreadedHeadlessTask< + TPayload extends JsonValue | void = void, +> = ( + context: ThreadedHeadlessTaskContext, ) => void | Promise; const threadedComponents: ThreadedComponentRegistry = new Map(); @@ -95,6 +100,24 @@ type ThreadedRuntimeNativeModule = { getRuntimeNames?: () => Promise; }; +export type ThreadableFunctionResult< + TReturn extends JsonValue | void = JsonValue | void, +> = TReturn | Promise; + +export type ThreadableFunction< + TArgs extends readonly JsonValue[] = readonly JsonValue[], + TReturn extends JsonValue | void = JsonValue | void, +> = (...args: TArgs) => ThreadableFunctionResult; + +/** Args for `T` — use instead of `Parameters` (which widens to `unknown[]`). */ +export type ThreadableFunctionArgs = + T extends ThreadableFunction + ? TArgs + : readonly JsonValue[]; + +export type ThreadableFunctionAwaitedReturn = + Awaited>; + const nativeRuntime = NativeModules.ThreadedRuntime as | ThreadedRuntimeNativeModule | undefined; @@ -205,43 +228,49 @@ export type ThreadedRuntimePrewarmOptions = { kind?: string; useMainNativeModules?: boolean; }; -export type ThreadedHeadlessTaskOptions = { - payload?: Payload; +export type ThreadedHeadlessTaskOptions< + TPayload extends JsonValue | void = void, +> = { + payload?: TPayload; runtimeName?: ThreadedRuntimeName; }; -type AnyFunction = (...args: any[]) => any; - export type RuntimeFunctionMetadata = { id: string; }; -export type RuntimeFunction = TFunction & { +export type RuntimeFunction< + TArgs extends readonly JsonValue[] = readonly JsonValue[], + TReturn extends JsonValue | void = JsonValue | void, +> = ThreadableFunction & { __runtimeFunction?: RuntimeFunctionMetadata; runOn( runtimeName: ThreadedRuntimeName, - ...args: Parameters - ): Promise>>; + ...args: TArgs + ): Promise>; }; -export type RuntimeFunctionCallBuilder = { +export type RuntimeFunctionCallBuilder< + TArgs extends readonly JsonValue[] = readonly JsonValue[], + TReturn extends JsonValue | void = JsonValue | void, +> = { on( runtimeName: ThreadedRuntimeName, - ): ( - ...args: Parameters - ) => Promise>>; + ): (...args: TArgs) => Promise>; }; export type RuntimeFunctionFactory = { - (fn: TFunction): RuntimeFunction; - withId( + ( + fn: ThreadableFunction, + ): RuntimeFunction; + withId( id: string, - fn: TFunction, - ): RuntimeFunction; - named( + fn: ThreadableFunction, + ): RuntimeFunction; + named( id: string, - fn: TFunction, - ): RuntimeFunction; + fn: ThreadableFunction, + ): RuntimeFunction; }; export type ThreadedProps> = { @@ -295,16 +324,21 @@ export function registerLazyThreadedComponent( threadedComponents.set(name, loadComponent as ThreadedComponentLoader); } -export function registerThreadedHeadlessTask( +export function registerThreadedHeadlessTask< + TPayload extends JsonValue | void = void, +>( name: string, - task: ThreadedHeadlessTask, + task: ThreadedHeadlessTask, ) { threadedHeadlessTasks.set(name, task as ThreadedHeadlessTask); } -export function registerRuntimeFunction( +export function registerRuntimeFunction< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, +>( id: string, - loadFunction: () => RuntimeFunction, + loadFunction: () => RuntimeFunction, ) { installRuntimeFunctionJsi(); runtimeFunctions.set(id, loadFunction as RuntimeFunctionLoader); @@ -314,26 +348,36 @@ export function registerRuntimeFunction( ); } -function attachRuntimeFunction( +function attachRuntimeFunction< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, +>( id: string | null, - fn: TFunction, -): RuntimeFunction { - const runtimeFn = fn as RuntimeFunction; + fn: ThreadableFunction, +): RuntimeFunction { + const runtimeFn = fn as RuntimeFunction; if (id) { runtimeFn.__runtimeFunction = { id }; } - runtimeFn.runOn = (runtimeName, ...args) => + runtimeFn.runOn = (runtimeName, ...args: TArgs) => ThreadedRuntime.run(runtimeName, runtimeFn, ...args); return runtimeFn; } -const createRuntimeFunction = ( - fn: TFunction, -): RuntimeFunction => attachRuntimeFunction(null, fn); +const createRuntimeFunction = < + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, +>( + fn: ThreadableFunction, +): RuntimeFunction => attachRuntimeFunction(null, fn); createRuntimeFunction.withId = function runtimeFunctionWithId< - TFunction extends AnyFunction, ->(id: string, fn: TFunction): RuntimeFunction { + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, +>( + id: string, + fn: ThreadableFunction, +): RuntimeFunction { return attachRuntimeFunction(id, fn); }; @@ -341,9 +385,12 @@ createRuntimeFunction.named = createRuntimeFunction.withId; export const runtimeFunction = createRuntimeFunction as RuntimeFunctionFactory; -export function call( - fn: RuntimeFunction, -): RuntimeFunctionCallBuilder { +export function call< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, +>( + fn: RuntimeFunction, +): RuntimeFunctionCallBuilder { return { on(runtimeName) { return (...args) => ThreadedRuntime.run(runtimeName, fn, ...args); @@ -571,6 +618,11 @@ async function runRegisteredRuntimeFunction( const result = await Promise.resolve( callRegisteredRuntimeFunction(functionId, argsJson), ); + + if (result !== undefined) { + assertJson(result, { for: 'result', id: functionId }); + } + await completeRuntimeFunctionCall( callId, JSON.stringify(result ?? null), @@ -700,15 +752,20 @@ export const ThreadedRuntime = { }); }, - runHeadlessTask( + runHeadlessTask( taskName: string, - options: ThreadedHeadlessTaskOptions = {}, + options: ThreadedHeadlessTaskOptions = {}, ) { if (Platform.OS !== 'android' && Platform.OS !== 'ios') { return Promise.resolve(); } const runtimeName = options.runtimeName ?? DEFAULT_RUNTIME_NAME; + + if (options.payload !== undefined) { + assertJson(options.payload, { for: 'payload', id: taskName }); + } + const payloadJson = JSON.stringify(options.payload ?? null); const nativeDispatch = nativeRuntime?.dispatchHeadlessTask ?? nativeRuntime?.runHeadlessTask; @@ -722,20 +779,21 @@ export const ThreadedRuntime = { return nativeDispatch(runtimeName, taskName, payloadJson); }, - async run( + async run< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, + >( runtimeName: ThreadedRuntimeName, - fn: RuntimeFunction, - ...args: Parameters - ): Promise>> { + fn: RuntimeFunction, + ...args: TArgs + ): Promise> { if (Platform.OS !== 'android' && Platform.OS !== 'ios') { - return Promise.resolve(fn(...args)) as Promise< - Awaited> - >; + return Promise.resolve(fn(...args)) as Promise>; } const functionId = fn.__runtimeFunction?.id; if (!functionId) { - return Promise.reject>>( + return Promise.reject>( new Error( 'Runtime function is missing generated metadata. Make sure it is ' + 'exported as runtimeFunction(...) and Metro uses withThreadedRuntime(...).', @@ -743,6 +801,8 @@ export const ThreadedRuntime = { ); } + assertJson(args, { id: functionId, for: 'arguments' }); + const argsJson = JSON.stringify(args); const runtimeNitro = getRuntimeFunctionsNitro(); if (runtimeNitro?.run) { @@ -752,7 +812,7 @@ export const ThreadedRuntime = { functionId, argsJson, ); - return JSON.parse(resultJson) as Awaited>; + return JSON.parse(resultJson) as Awaited; } catch (error) { if (!isRuntimeDispatcherMissing(error)) { throw error; @@ -762,7 +822,7 @@ export const ThreadedRuntime = { const callRuntimeFunction = nativeRuntime?.callRuntimeFunction; if (!callRuntimeFunction) { - return Promise.reject>>( + return Promise.reject>( new Error( 'ThreadedRuntime native module does not support runtime functions', ), @@ -774,22 +834,28 @@ export const ThreadedRuntime = { functionId, argsJson, ); - return JSON.parse(resultJson) as Awaited>; + return JSON.parse(resultJson) as Awaited; }, - call( + call< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, + >( runtimeName: ThreadedRuntimeName, - fn: RuntimeFunction, - ...args: Parameters + fn: RuntimeFunction, + ...args: TArgs ) { return ThreadedRuntime.run(runtimeName, fn, ...args); }, runtime(runtimeName: ThreadedRuntimeName) { return { - run( - fn: RuntimeFunction, - ...args: Parameters + run< + TArgs extends readonly JsonValue[], + TReturn extends JsonValue | void, + >( + fn: RuntimeFunction, + ...args: TArgs ) { return ThreadedRuntime.run(runtimeName, fn, ...args); }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 52550e4..cac35d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,4 +31,8 @@ export { type RuntimeFunctionCallBuilder, type RuntimeFunctionFactory, type RuntimeFunctionMetadata, -} from './ThreadedRuntime'; + type ThreadableFunction, + type ThreadableFunctionArgs, + type ThreadableFunctionAwaitedReturn, + type ThreadableFunctionResult, +} from './ThreadedRuntime'; \ No newline at end of file diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts new file mode 100644 index 0000000..1af59c2 --- /dev/null +++ b/packages/core/src/json.ts @@ -0,0 +1,44 @@ +export type JsonPrimitive = string | number | boolean | null; + +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export type JsonObject = { + readonly [key: string]: JsonValue; +}; + +export type JsonArray = readonly JsonValue[]; + +const validateJson = (value: unknown): value is JsonValue => { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return true; + } + if (Array.isArray(value)) { + return value.every(validateJson); + } + if (typeof value === 'object' && value !== null) { + return Object.values(value).every(validateJson); + } + + return false; +}; + +export type AssertJsonContext = { + readonly id: string; + readonly for: 'arguments' | 'payload' | 'result'; +}; + +export function assertJson( + value: unknown, + context: AssertJsonContext, +): asserts value is JsonValue { + if (__DEV__) { + if (!validateJson(value)) { + throw new Error(`Invalid JSON value for ${context.for} of "${context.id}"`); + } + } +}