From 7e9be7a50ca7de0e4027c05f12a659beebb406d4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 25 May 2026 11:41:21 +0200 Subject: [PATCH 1/2] feat: validate args and return types --- packages/core/src/ThreadedRuntime.tsx | 149 +++++++++++++++++--------- packages/core/src/index.ts | 6 +- packages/core/src/json.ts | 44 ++++++++ 3 files changed, 150 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/json.ts diff --git a/packages/core/src/ThreadedRuntime.tsx b/packages/core/src/ThreadedRuntime.tsx index d4641b9..d75f09a 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'; @@ -95,6 +96,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; @@ -210,38 +229,42 @@ export type ThreadedHeadlessTaskOptions = { 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> = { @@ -302,9 +325,12 @@ export function registerThreadedHeadlessTask( 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 +340,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 +377,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 +610,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), @@ -722,20 +766,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 +788,8 @@ export const ThreadedRuntime = { ); } + assertJson(args, { id: functionId, for: 'arguments' }); + const argsJson = JSON.stringify(args); const runtimeNitro = getRuntimeFunctionsNitro(); if (runtimeNitro?.run) { @@ -752,7 +799,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 +809,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 +821,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..e4cff66 --- /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' | '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}"`); + } + } +} From 9e3c6312114d454b8a7a7135e5163d7b3d310906 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 25 May 2026 12:12:44 +0200 Subject: [PATCH 2/2] feat: guard headless tasks payloads --- packages/core/src/ThreadedRuntime.tsx | 33 +++++++++++++++++++-------- packages/core/src/json.ts | 2 +- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/core/src/ThreadedRuntime.tsx b/packages/core/src/ThreadedRuntime.tsx index d75f09a..cf1b916 100644 --- a/packages/core/src/ThreadedRuntime.tsx +++ b/packages/core/src/ThreadedRuntime.tsx @@ -32,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(); @@ -224,8 +228,10 @@ export type ThreadedRuntimePrewarmOptions = { kind?: string; useMainNativeModules?: boolean; }; -export type ThreadedHeadlessTaskOptions = { - payload?: Payload; +export type ThreadedHeadlessTaskOptions< + TPayload extends JsonValue | void = void, +> = { + payload?: TPayload; runtimeName?: ThreadedRuntimeName; }; @@ -318,9 +324,11 @@ 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); } @@ -744,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; diff --git a/packages/core/src/json.ts b/packages/core/src/json.ts index e4cff66..1af59c2 100644 --- a/packages/core/src/json.ts +++ b/packages/core/src/json.ts @@ -29,7 +29,7 @@ const validateJson = (value: unknown): value is JsonValue => { export type AssertJsonContext = { readonly id: string; - readonly for: 'arguments' | 'result'; + readonly for: 'arguments' | 'payload' | 'result'; }; export function assertJson(