import { createHash } from 'node:crypto' import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { TransformOptions } from '@babel/core' import type { TransformResult } from 'vite' const sootsimPluginRequire = createRequire(import.meta.url) const sootsimRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const workspaceRoot = path.resolve(sootsimRoot, '..', '..') // handle files containing upstream reanimated/worklets worklet directives and // inline updaters to auto-workletizable hooks. the babel transform lifts those // into functions carrying `__closure`, `__workletHash`, and `__initData`. export const REANIMATED_AUTOWORKLETIZATION_KEYWORDS = [ 'worklet', 'useAnimatedGestureHandler', 'useAnimatedScrollHandler', 'useFrameCallback', 'useAnimatedStyle', 'useAnimatedProps', 'createAnimatedPropAdapter', 'useDerivedValue', 'useAnimatedReaction', 'useWorkletCallback', 'withTiming', 'withSpring', 'withDecay', 'withRepeat', 'runOnUI', 'executeOnUIRuntimeSync', ] export const REANIMATED_WORKLETIZATION_REGEX = new RegExp( REANIMATED_AUTOWORKLETIZATION_KEYWORDS.join('|'), ) // false-positive packages: they mention the keywords in strings/comments but // don't actually use worklets, or transforming them causes circular deps / // breaks the build. keep this minimal; the regex above is already // content-gated so most of node_modules skips for free. const REANIMATED_IGNORED_PATHS_REGEX = /node_modules\/(react|react-dom|react-native|react-native-web)\// const SOOTSIM_ENGINE_SRC_PATH = '/packages/sootsim-engine/src/' const SOOTSIM_ENGINE_TEST_FIXTURE_PATH = '/packages/sootsim-engine/src/test-fixtures/' const REANIMATED_IMPORT_REGEX = /(?:from\s+['"]react-native-reanimated(?:\/[^'"]*)?['"]|require\(\s*['"]react-native-reanimated(?:\/[^'"]*)?['"]\s*\))/ let workletsPluginPath: string | null = null let babelCorePromise: Promise | null = null export function shouldApplyWorkletsPlugin(id: string, code: string): boolean { if (!/\.(tsx?|jsx?|mjs|cjs|mts|cts)$/.test(id)) return false if (REANIMATED_IGNORED_PATHS_REGEX.test(id)) return false // sootsim engine source has local helpers named like upstream reanimated // hooks. only transform engine files that actually import upstream // react-native-reanimated; otherwise the plugin rewrites callbacks for our // own engine hooks and expects a `this.__closure` binding they never provide. if ( id.includes(SOOTSIM_ENGINE_SRC_PATH) && !id.includes(SOOTSIM_ENGINE_TEST_FIXTURE_PATH) && !REANIMATED_IMPORT_REGEX.test(code) ) { return false } return REANIMATED_WORKLETIZATION_REGEX.test(code) } function getWorkletsPluginPath(): string { if (!workletsPluginPath) { workletsPluginPath = path.resolve( workspaceRoot, 'node_modules', 'react-native-worklets', 'plugin', ) } return workletsPluginPath } function isTypescriptFile(id: string): boolean { return /\.(tsx?|mts|cts)$/.test(id) } // the worklets babel pass is the single most expensive plugin hook in the // engine and tenant builds (hundreds of files, several seconds each). the // output is a pure function of the source text, the file path, the sourcemap // flag, and the babel toolchain, so cache it on disk keyed by all four. a // normal dev rebuild changes a handful of files and replays the rest. const CACHE_SCHEMA = 'v1' const CACHE_ROOT = path.resolve( workspaceRoot, 'node_modules', '.cache', 'sootsim-worklets', ) // null once resolved on a tree we cannot write to (a read-only install), which // only costs speed: every caller falls through to the babel run it would have // made anyway. let cacheDir: string | null | undefined function getCacheDir(): string | null { if (cacheDir !== undefined) return cacheDir const versions = [ '@babel/core', '@babel/preset-typescript', 'react-native-worklets', ].map((pkg) => { const manifest = sootsimPluginRequire(`${pkg}/package.json`) as { version?: string } return `${pkg}@${manifest.version ?? '0'}` }) const fingerprint = createHash('sha256') .update([CACHE_SCHEMA, ...versions].join('\n')) .digest('hex') .slice(0, 16) const dir = path.join(CACHE_ROOT, fingerprint) try { fs.mkdirSync(dir, { recursive: true }) // a toolchain bump starts a new fingerprint, so drop the previous ones // instead of letting every upgrade leave its entries behind forever. for (const entry of fs.readdirSync(CACHE_ROOT)) { if (entry === fingerprint) continue fs.rmSync(path.join(CACHE_ROOT, entry), { recursive: true, force: true }) } cacheDir = dir } catch { cacheDir = null } return cacheDir } function getCacheEntryPath( code: string, id: string, sourceMaps: TransformOptions['sourceMaps'], ): string | null { const dir = getCacheDir() if (!dir) return null // the worklets plugin's `isRelease` reads BABEL_ENV and NODE_ENV and emits a // different worklet for each (release drops the embedded source and map), so // both belong in the key. the same file is built once as a dev tenant bundle // and once as a production engine bundle. const key = createHash('sha256') .update( [ id, String(sourceMaps), process.env.BABEL_ENV ?? '', process.env.NODE_ENV ?? '', code, ].join('\0'), ) .digest('hex') return path.join(dir, key.slice(0, 2), `${key.slice(2)}.json`) } function readCachedTransform( entryPath: string | null, ): Pick | null { if (!entryPath) return null try { return JSON.parse(fs.readFileSync(entryPath, 'utf8')) } catch { return null } } function writeCachedTransform( entryPath: string | null, result: Pick, ): void { if (!entryPath) return try { fs.mkdirSync(path.dirname(entryPath), { recursive: true }) // write-then-rename so a concurrent build never reads a half-written entry. const temp = `${entryPath}.${process.pid}.${Math.random().toString(36).slice(2)}` fs.writeFileSync(temp, JSON.stringify(result)) fs.renameSync(temp, entryPath) } catch {} } function createWorkletsBabelOptions( id: string, sourceMaps: TransformOptions['sourceMaps'], ): TransformOptions { const isTSX = id.endsWith('.tsx') const isTS = isTypescriptFile(id) return { filename: id, babelrc: false, configFile: false, sourceMaps, presets: isTS ? [ [ sootsimPluginRequire.resolve('@babel/preset-typescript'), { isTSX, allExtensions: isTSX, allowDeclareFields: true }, ], ] : [], plugins: [ [sootsimPluginRequire.resolve('@babel/plugin-syntax-jsx')], [getWorkletsPluginPath(), { processNestedWorklets: true }], ], } } export async function transformWorkletsCode( code: string, id: string, sourceMaps: TransformOptions['sourceMaps'] = true, ): Promise | null> { const entryPath = getCacheEntryPath(code, id, sourceMaps) const cached = readCachedTransform(entryPath) if (cached) return cached if (!babelCorePromise) { babelCorePromise = import('@babel/core') as Promise } const { transformAsync } = await babelCorePromise const result = await transformAsync(code, createWorkletsBabelOptions(id, sourceMaps)) if (!result?.code) return null const transformed = { code: result.code, map: (result.map ?? null) as TransformResult['map'], } writeCachedTransform(entryPath, transformed) return transformed } export function transformWorkletsCodeSync( code: string, id: string, sourceMaps: TransformOptions['sourceMaps'] = false, ): Pick | null { const entryPath = getCacheEntryPath(code, id, sourceMaps) const cached = readCachedTransform(entryPath) if (cached) return cached const { transformSync } = sootsimPluginRequire( '@babel/core', ) as typeof import('@babel/core') const result = transformSync(code, createWorkletsBabelOptions(id, sourceMaps)) if (!result?.code) return null const transformed = { code: result.code, map: (result.map ?? null) as TransformResult['map'], } writeCachedTransform(entryPath, transformed) return transformed }