diff --git a/.changeset/client-rendered-widgets-experimental.md b/.changeset/client-rendered-widgets-experimental.md new file mode 100644 index 00000000..f64459cf --- /dev/null +++ b/.changeset/client-rendered-widgets-experimental.md @@ -0,0 +1,14 @@ +--- +'@use-voltra/ios-client': minor +--- + +**Experimental: client-rendered widgets (iOS).** A widget component marked with the `'use voltra'` +directive now renders on-device from its own JS bundle, called as `(props, env) => JSX` on every +render, so it reacts to live environment values (widget family, color scheme, locale, and +user-editable `configuration` via a native AppIntent "Edit Widget" sheet). In development the +bundle is served by Metro and editing the JSX hot-reloads the home-screen widget; in release builds +the bundle is baked into the widget extension at build time. + +This feature is **experimental** — usable in production at your own risk; the API and generated +build output may change. Verify release rendering on a real device (the iOS Simulator is unreliable +for widget rendering). diff --git a/.changeset/twelve-widgets-walk.md b/.changeset/twelve-widgets-walk.md new file mode 100644 index 00000000..ca2c896a --- /dev/null +++ b/.changeset/twelve-widgets-walk.md @@ -0,0 +1,7 @@ +--- +'@use-voltra/compiler': minor +'@use-voltra/ios-client': minor +'@use-voltra/metro': minor +--- + +Add the Voltra compiler package for shared directive scanning, wire Metro and iOS prebuild validation to it, and keep the Metro scanner subpath as a re-export. diff --git a/.gitignore b/.gitignore index 34294cb3..7df4354e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ npm-debug.log ## Build /build -/packages/ios-client/ios/.build/ +# SPM build caches under any iOS package (ios-client, voltra, etc.) +/packages/*/ios/.build/ diff --git a/example/.gitignore b/example/.gitignore index d0e12830..aa83371d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -20,6 +20,7 @@ expo-env.d.ts # Metro .metro-health-check* +.voltra/ # debug npm-debug.* @@ -37,4 +38,4 @@ yarn-error.* *.tsbuildinfo /ios -/android \ No newline at end of file +/android diff --git a/example/app.json b/example/app.json index 752d545d..e5b8050e 100644 --- a/example/app.json +++ b/example/app.json @@ -49,6 +49,28 @@ "pl": "./widgets/ios/ios-weather-initial.tsx" } }, + { + "id": "ClientRenderedDemoWidget", + "displayName": { + "en": "Client-Rendered Demo", + "pl": "Client-Rendered Demo" + }, + "description": { + "en": "Plain widget showing env values — for verifying client-rendered hot reload.", + "pl": "Prosty widget pokazujący wartości env — do weryfikacji hot reload." + }, + "supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"], + "initialStatePath": "./widgets/ios/ClientRenderedDemoWidget.tsx", + "appIntent": { + "parameters": [ + { + "name": "label", + "title": "Label", + "default": "Hello" + } + ] + } + }, { "id": "portfolio", "displayName": { diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 5b9e45a4..9970620d 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,10 +1,13 @@ import { Stack } from 'expo-router' import { SafeAreaProvider } from 'react-native-safe-area-context' +import { enableWidgetHotReload } from '@use-voltra/ios-client' +import '@use-voltra/widget-hot-reload' import { useVoltraEvents } from '~/hooks/useVoltraEvents' import { useServerDrivenWidgetToken } from '~/hooks/useServerDrivenWidgetToken' import { updateAndroidVoltraWidget } from '~/widgets/android/updateAndroidVoltraWidget' +enableWidgetHotReload() updateAndroidVoltraWidget({ width: 300, height: 200 }) const STACK_SCREEN_OPTIONS = { diff --git a/example/metro.config.js b/example/metro.config.js new file mode 100644 index 00000000..7faad9a8 --- /dev/null +++ b/example/metro.config.js @@ -0,0 +1,23 @@ +const path = require('node:path') + +const { getDefaultConfig } = require('expo/metro-config') +const { withVoltra } = require('@use-voltra/metro') + +const config = getDefaultConfig(__dirname) +const repoRoot = path.resolve(__dirname, '..') + +config.resolver.extraNodeModules = { + ...config.resolver.extraNodeModules, + '@use-voltra/android': path.join(repoRoot, 'packages/android'), + '@use-voltra/android-client': path.join(repoRoot, 'packages/android-client'), + '@use-voltra/core': path.join(repoRoot, 'packages/core'), + '@use-voltra/expo-plugin': path.join(repoRoot, 'packages/expo-plugin'), + '@use-voltra/ios': path.join(repoRoot, 'packages/ios'), + '@use-voltra/ios-client': path.join(repoRoot, 'packages/ios-client'), + '@use-voltra/server': path.join(repoRoot, 'packages/server'), + '~': __dirname, +} + +config.watchFolders = Array.from(new Set([...(config.watchFolders || []), path.join(repoRoot, 'packages')])) + +module.exports = withVoltra(config) diff --git a/example/package.json b/example/package.json index 23ea1d9f..7095fd19 100644 --- a/example/package.json +++ b/example/package.json @@ -51,6 +51,7 @@ "react-native-web": "^0.21.0", "react-native-webview": "13.16.0", "react-native-worklets": "~0.7.0", + "@use-voltra/metro": "workspace:*", "@use-voltra/ios": "workspace:*", "@use-voltra/ios-client": "workspace:*", "@use-voltra/android": "workspace:*", diff --git a/example/widgets/ios/ClientRenderedDemoWidget.tsx b/example/widgets/ios/ClientRenderedDemoWidget.tsx new file mode 100644 index 00000000..8b18abdd --- /dev/null +++ b/example/widgets/ios/ClientRenderedDemoWidget.tsx @@ -0,0 +1,65 @@ +import { Voltra, type WidgetEnvironment } from '@use-voltra/ios' + +// Minimal client-rendered widget for verifying the dev loop. +// +// Plain black tile with the env values the runtime captured per render, plus a single +// editable literal (`hotReloadMarker` below) for proving hot reload end-to-end. +// Edit the literal, save, watch the home-screen widget update within ~1 second. + +export const ClientRenderedDemoWidget = (_props: object, env: WidgetEnvironment = {} as WidgetEnvironment) => { + 'use voltra' + + // ▼ EDIT THIS LITERAL TO TEST HOT RELOAD ▼ + const hotReloadMarker = 'edit me' + + const date = env.date ? new Date(env.date) : new Date() + const renderedAt = date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + const config = env.configuration as Record | undefined + const configLabel = typeof config?.label === 'string' ? config.label : '(unset)' + + const labelStyle = { fontSize: 9, color: '#FFFFFF' } as const + const valueStyle = { fontSize: 9, color: '#94A3B8' } as const + + return ( + + Client-rendered demo + + {hotReloadMarker} + + + family: + {env.widgetFamily ?? '?'} + + + scheme: + {env.colorScheme ?? '?'} + + + mode: + {env.widgetRenderingMode ?? '?'} + + + locale: + {env.locale ?? '?'} + + + config: + {configLabel} + + + dev: + {String(env.build?.isDev ?? '?')} + + + time: + {renderedAt} + + + ) +} diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts index 6472195f..a849cb61 100644 --- a/packages/android/src/index.ts +++ b/packages/android/src/index.ts @@ -91,3 +91,5 @@ export type { LinearProgressIndicatorProps } from './jsx/LinearProgressIndicator export type { RowProps } from './jsx/Row.js' export type { SpacerProps } from './jsx/Spacer.js' export type { TextProps } from './jsx/Text.js' +export { isAndroidEnv, isIosEnv } from '@use-voltra/core' +export type { MaterialColorScheme, WidgetBuildEnvironment, WidgetEnvironment } from '@use-voltra/core' diff --git a/packages/compiler/README.md b/packages/compiler/README.md new file mode 100644 index 00000000..e64670f2 --- /dev/null +++ b/packages/compiler/README.md @@ -0,0 +1,93 @@ +![voltra-banner](https://use-voltra.dev/voltra-baner.jpg) + +### Shared source analysis utilities for Voltra + +[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome] + +`@use-voltra/compiler` contains the source analysis helpers that power Voltra's client-rendered widget workflow. It parses JavaScript and TypeScript files, finds `'use voltra'` directives, and returns the widget records that Metro and related tooling consume. + +## Features + +- **Directive scanning**: Detect `'use voltra'` in function declarations, function expressions, and arrow functions. + +- **Export-aware analysis**: Match directives to named and default exports so only real widget entry points are returned. + +- **TypeScript and JSX support**: Parse common React and React Native source formats, including `.ts`, `.tsx`, `.js`, and `.jsx` files. + +- **Shared by Voltra tooling**: Powers `@use-voltra/metro` and other build-time widget workflows. + +## Documentation + +This package is an internal building block for Voltra's widget toolchain. Relevant topics: + +- [Getting Started](https://use-voltra.dev/getting-started/installation) +- [iOS Widgets](https://use-voltra.dev/ios/development/developing-widgets) +- [Android Widgets](https://use-voltra.dev/android/development/developing-widgets) + +## Getting started + +`@use-voltra/compiler` is usually installed as a transitive dependency of the Metro package, but you can install it directly if you want to build custom tooling on top of Voltra's source scanner. + +```sh +npm install @use-voltra/compiler +``` + +Use `scanVoltraDirectives()` to inspect a file: + +```ts +import { scanVoltraDirectives } from '@use-voltra/compiler' + +const widgets = scanVoltraDirectives({ + filePath: '/app/widgets/OrderTracker.tsx', + source: ` + export function OrderTracker() { + 'use voltra' + return null + } + `, +}) + +console.log(widgets) +``` + +## Quick example + +```ts +import { scanVoltraDirectives } from '@use-voltra/compiler' + +const widgets = scanVoltraDirectives({ + filePath: 'src/widgets/WeatherWidget.tsx', + source: ` + export const WeatherWidget = () => { + 'use voltra' + return null + } + `, +}) + +for (const widget of widgets) { + console.log(widget.id, widget.exportName, widget.sourcePath) +} +``` + +## Platform compatibility + +This package is runtime-agnostic and works in Node.js or build-time tooling that needs to analyze Voltra widget source code. + +## Authors + +Voltra is an open source collaboration between [Saúl Sharma](https://github.com/saulsharma) and [Szymon Chmal](https://github.com/szymonchmal) at [Callstack][callstack-readme-with-love]. + +If you think it's cool, please star it 🌟. This project will always remain free to use. + +[Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the Callstack team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=voltra&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/@use-voltra/compiler?style=for-the-badge +[license]: https://github.com/callstackincubator/voltra/blob/main/LICENSE.txt +[npm-downloads-badge]: https://img.shields.io/npm/dm/@use-voltra/compiler?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/@use-voltra/compiler +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ../../CONTRIBUTING.md diff --git a/packages/compiler/package.json b/packages/compiler/package.json new file mode 100644 index 00000000..0121af7b --- /dev/null +++ b/packages/compiler/package.json @@ -0,0 +1,48 @@ +{ + "name": "@use-voltra/compiler", + "version": "1.4.1", + "description": "Shared Voltra source analysis utilities", + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + ".": { + "types": "./build/types/index.d.ts", + "require": "./build/cjs/index.js", + "import": "./build/esm/index.js", + "default": "./build/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "build", + "README.md" + ], + "scripts": { + "build": "node ../../scripts/build-package.mjs packages/compiler", + "clean": "rm -rf build", + "lint": "oxlint src", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "test": "node --test" + }, + "dependencies": { + "@babel/parser": "^7.27.4", + "@babel/types": "^7.27.4" + }, + "keywords": [ + "voltra", + "compiler", + "directive" + ], + "author": "Saúl Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/voltra.git", + "directory": "packages/compiler" + }, + "bugs": { + "url": "https://github.com/callstackincubator/voltra/issues" + }, + "license": "MIT", + "homepage": "https://use-voltra.dev" +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts new file mode 100644 index 00000000..e9f7b266 --- /dev/null +++ b/packages/compiler/src/index.ts @@ -0,0 +1,241 @@ +import path from 'node:path' + +import * as parser from '@babel/parser' + +const supportedExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']) + +export type VoltraDirectiveWidget = { + id: string + componentName: string + exportName: string + sourcePath: string +} + +type ExportedLocals = Map> + +type DirectiveFunction = + | import('@babel/types').FunctionDeclaration + | import('@babel/types').FunctionExpression + | import('@babel/types').ArrowFunctionExpression + +function hasVoltraDirective( + functionNode: import('@babel/types').Node | null | undefined +): functionNode is DirectiveFunction { + if ( + !functionNode || + (functionNode.type !== 'FunctionDeclaration' && + functionNode.type !== 'FunctionExpression' && + functionNode.type !== 'ArrowFunctionExpression') + ) { + return false + } + + const { body } = functionNode + if (body.type !== 'BlockStatement') { + return false + } + + return body.directives.some((item) => item.value?.value === 'use voltra') +} + +function parseSource(source: string, filePath: string): ReturnType { + return parser.parse(source, { + sourceFilename: filePath, + sourceType: 'unambiguous', + plugins: [ + 'classProperties', + 'decorators-legacy', + 'dynamicImport', + 'exportDefaultFrom', + 'importMeta', + 'jsx', + 'topLevelAwait', + 'typescript', + ], + }) +} + +function identifierName(node: import('@babel/types').Node | null | undefined): string | null { + return node?.type === 'Identifier' ? node.name : null +} + +function exportName( + node: import('@babel/types').Identifier | import('@babel/types').StringLiteral | null | undefined +): string | null { + if (!node) { + return null + } + if (node.type === 'Identifier') { + return node.name + } + if (node.type === 'StringLiteral') { + return node.value + } + return null +} + +function collectExportedLocals(program: import('@babel/types').Program): ExportedLocals { + const exportedLocals: ExportedLocals = new Map() + + function add(localName: string | null, exportedName: string | null) { + if (!localName || !exportedName) { + return + } + + const exports = exportedLocals.get(localName) || new Set() + exports.add(exportedName) + exportedLocals.set(localName, exports) + } + + for (const statement of program.body) { + if (statement.type === 'ExportNamedDeclaration') { + if (statement.declaration) { + if (statement.declaration.type === 'FunctionDeclaration') { + add(statement.declaration.id?.name ?? null, statement.declaration.id?.name ?? null) + } + + if (statement.declaration.type === 'VariableDeclaration') { + for (const declaration of statement.declaration.declarations) { + const name = identifierName(declaration.id) + add(name, name) + } + } + } + + for (const specifier of statement.specifiers) { + if (specifier.type === 'ExportSpecifier') { + add(exportName(specifier.local), exportName(specifier.exported)) + } + } + } + + if (statement.type === 'ExportDefaultDeclaration' && statement.declaration.type === 'Identifier') { + add(statement.declaration.name, 'default') + } + } + + return exportedLocals +} + +function createWidgetRecord({ + componentName, + exportedName, + filePath, +}: { + componentName: string | null + exportedName: string + filePath: string +}): VoltraDirectiveWidget | null { + if (!componentName || componentName === 'default') { + return null + } + + return { + id: componentName, + componentName, + exportName: exportedName, + sourcePath: filePath, + } +} + +function scanDeclaration({ + declaration, + exportedLocals, + filePath, +}: { + declaration: import('@babel/types').Declaration | import('@babel/types').Statement | null | undefined + exportedLocals: ExportedLocals + filePath: string +}): VoltraDirectiveWidget[] { + if (!declaration) { + return [] + } + + if (declaration.type === 'FunctionDeclaration') { + const componentName = declaration.id?.name ?? null + const exportNames = componentName ? exportedLocals.get(componentName) : null + + if (!hasVoltraDirective(declaration) || !exportNames) { + return [] + } + + return Array.from(exportNames) + .map((name) => createWidgetRecord({ componentName, exportedName: name, filePath })) + .filter((widget): widget is VoltraDirectiveWidget => widget !== null) + } + + if (declaration.type === 'VariableDeclaration') { + const widgets: VoltraDirectiveWidget[] = [] + + for (const variableDeclarator of declaration.declarations) { + const componentName = identifierName(variableDeclarator.id) + const init = variableDeclarator.init + const exportNames = componentName ? exportedLocals.get(componentName) : null + + if (!hasVoltraDirective(init) || !exportNames) { + continue + } + + for (const name of exportNames) { + const widget = createWidgetRecord({ componentName, exportedName: name, filePath }) + + if (widget) { + widgets.push(widget) + } + } + } + + return widgets + } + + return [] +} + +export function scanVoltraDirectives({ + filePath, + source, +}: { + filePath: string + source: string +}): VoltraDirectiveWidget[] { + if (!supportedExtensions.has(path.extname(filePath))) { + return [] + } + + if (!source.includes("'use voltra'") && !source.includes('"use voltra"')) { + return [] + } + + const ast = parseSource(source, filePath) + const exportedLocals = collectExportedLocals(ast.program) + const widgets: VoltraDirectiveWidget[] = [] + + for (const statement of ast.program.body) { + if (statement.type === 'ExportDefaultDeclaration') { + if (hasVoltraDirective(statement.declaration)) { + const componentName = + statement.declaration.type === 'FunctionDeclaration' ? statement.declaration.id?.name ?? null : null + const widget = createWidgetRecord({ + componentName, + exportedName: 'default', + filePath, + }) + + if (widget) { + widgets.push(widget) + } + } + + continue + } + + if (statement.type === 'ExportNamedDeclaration') { + widgets.push(...scanDeclaration({ declaration: statement.declaration, exportedLocals, filePath })) + continue + } + + widgets.push(...scanDeclaration({ declaration: statement, exportedLocals, filePath })) + } + + return widgets +} diff --git a/packages/compiler/tsconfig.base.json b/packages/compiler/tsconfig.base.json new file mode 100644 index 00000000..452d729b --- /dev/null +++ b/packages/compiler/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "rootDir": "./src", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"] +} diff --git a/packages/compiler/tsconfig.cjs.json b/packages/compiler/tsconfig.cjs.json new file mode 100644 index 00000000..a6b3ca9c --- /dev/null +++ b/packages/compiler/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./build/cjs", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/compiler/tsconfig.esm.json b/packages/compiler/tsconfig.esm.json new file mode 100644 index 00000000..2bb18d33 --- /dev/null +++ b/packages/compiler/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/esm", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/compiler/tsconfig.json b/packages/compiler/tsconfig.json new file mode 100644 index 00000000..09b0ecb8 --- /dev/null +++ b/packages/compiler/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.esm.json" }, + { "path": "./tsconfig.cjs.json" }, + { "path": "./tsconfig.types.json" } + ] +} diff --git a/packages/compiler/tsconfig.typecheck.json b/packages/compiler/tsconfig.typecheck.json new file mode 100644 index 00000000..77b33299 --- /dev/null +++ b/packages/compiler/tsconfig.typecheck.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "rootDir": "../..", + "baseUrl": "../..", + "paths": { + "@use-voltra/android": ["packages/android/src/index.ts"], + "@use-voltra/android-client": ["packages/android-client/src/index.ts"], + "@use-voltra/android/client": ["packages/android-client/src/index.ts"], + "@use-voltra/android/server": ["packages/android/src/server.ts"], + "@use-voltra/android-server": ["packages/android-server/src/index.ts"], + "@use-voltra/compiler": ["packages/compiler/src/index.ts"], + "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/ios": ["packages/ios/src/index.ts"], + "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], + "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], + "@use-voltra/ios/server": ["packages/ios/src/server.ts"], + "@use-voltra/ios-server": ["packages/ios-server/src/index.ts"], + "@use-voltra/server": ["packages/server/src/index.ts"] + } + } +} diff --git a/packages/compiler/tsconfig.types.json b/packages/compiler/tsconfig.types.json new file mode 100644 index 00000000..8ec821ee --- /dev/null +++ b/packages/compiler/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/types", + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf73c506..442aa610 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from './payload.js' export * from './payload/short-names.js' export * from './renderer/index.js' export * from './types.js' +export * from './widget-environment.js' diff --git a/packages/core/src/widget-environment.ts b/packages/core/src/widget-environment.ts new file mode 100644 index 00000000..aec480d3 --- /dev/null +++ b/packages/core/src/widget-environment.ts @@ -0,0 +1,154 @@ +/** + * Env shape consumed by client-rendered widgets. + * + * Client-rendered widgets are functions of `(props, env) => JSX`, evaluated inside the + * Voltra JS runtime (JSC on iOS, Hermes on Android) at every render. The `env` second + * argument is populated by the native runtime at draw time and carries: + * + * - **Runtime device state** (`colorScheme`, `widgetFamily`, etc.) captured per render + * - **Platform-specific runtime state** (`widgetRenderingMode` on iOS, `materialColors` on + * Android), present only on the platform that has the concept + * - **AppIntent / user-configured params** under `env.configuration` (TypeScript-typed per + * widget via the generic parameter) + * - **Build env** under `env.build.*` — values that don't change between renders inside a + * process (isDev, Metro URL, app version, Voltra version) + * + * The shape mirrors expo-widgets' `WidgetEnvironment` for the runtime device fields, with + * a Voltra-specific `env.build.*` namespace added for dev-mode tooling. + * + * @typeParam TConfig - Shape of `env.configuration` (AppIntent / user-configured params). + * Defaults to `undefined` for widgets that don't accept user configuration. Widget authors + * can supply a more specific type per widget for typed access. + */ +export type WidgetEnvironment | undefined = undefined> = { + /** Date the widget is being rendered for. Transported as epoch ms over the JS boundary + * and reconstructed as `Date` by the runtime entry. */ + date: Date + + /** Widget size family. iOS values: `systemSmall`, `systemMedium`, `systemLarge`, etc. + * Android values: synthesized from Glance `LocalSize` (e.g. `"200x200"`). */ + widgetFamily: string + + /** Current color scheme of the widget's environment. May be `undefined` if the platform + * doesn't expose it (rare). */ + colorScheme?: 'light' | 'dark' + + /** BCP-47 locale tag — for example `"en-US"` or `"pl-PL"`. */ + locale?: string + + // --------------------------------------------------------------------------- + // iOS-only runtime values + // Present only when rendering on iOS; `undefined` on Android. + // --------------------------------------------------------------------------- + + /** iOS — rendering mode the widget is being drawn in. `fullColor` on home screen, + * `accented` on tinted/Liquid Glass widgets (iOS 18+) and watchOS, `vibrant` on lock + * screen. Maps to SwiftUI `@Environment(\.widgetRenderingMode)`. */ + widgetRenderingMode?: 'fullColor' | 'accented' | 'vibrant' + + /** iOS — whether the system is drawing a container background behind the widget. + * Maps to SwiftUI `@Environment(\.showsWidgetContainerBackground)`. iOS 17+. */ + showsWidgetContainerBackground?: boolean + + // --------------------------------------------------------------------------- + // Android-only runtime values + // Present only when rendering on Android; `undefined` on iOS. + // --------------------------------------------------------------------------- + + /** Android — Material You dynamic color tokens captured from + * `MaterialTheme.colorScheme`. Field-for-field maps onto Compose `ColorScheme` + * (primary, onPrimary, surface, onSurface, etc.). */ + materialColors?: MaterialColorScheme + + // --------------------------------------------------------------------------- + // System-managed configuration + // --------------------------------------------------------------------------- + + /** AppIntent / user-configured parameters for this widget. `undefined` for widgets that + * don't accept user configuration. Typed per widget via the [TConfig] generic. */ + configuration: TConfig + + // --------------------------------------------------------------------------- + // Build env — static for the process lifetime, supplied by the runtime + // --------------------------------------------------------------------------- + + /** Build / process-level metadata, populated by the runtime once per process. Static for + * the JS runtime's lifetime; does not change between renders. */ + build: WidgetBuildEnvironment +} + +/** + * Build / process metadata available inside the widget render function. Populated by the + * native runtime; identical across every render in a process. + */ +export type WidgetBuildEnvironment = { + /** True when running against a development build (DEBUG / `__DEV__`). Used to gate + * dev-mode behaviour like fetching bundles from Metro. */ + isDev: boolean + + /** URL of the Metro dev server when `isDev` is true. Used by the runtime to fetch widget + * bundles for hot-reload. `undefined` in release builds. */ + metroUrl?: string + + /** App version string (`CFBundleShortVersionString` on iOS, `versionName` on Android). */ + appVersion: string + + /** Voltra package version (`@use-voltra/core`). Surfaces in error reports and lets + * widgets gate behaviour by compatibility level if needed. */ + voltraVersion: string +} + +/** + * Material You dynamic color tokens captured from Android `MaterialTheme.colorScheme`. + * Field names map 1:1 onto Compose's `ColorScheme` properties. Each value is an RGBA + * hex string (`#RRGGBBAA` or `#RRGGBB`). Available on Android only. + */ +export type MaterialColorScheme = { + primary: string + onPrimary: string + primaryContainer: string + onPrimaryContainer: string + secondary: string + onSecondary: string + secondaryContainer: string + onSecondaryContainer: string + tertiary: string + onTertiary: string + tertiaryContainer: string + onTertiaryContainer: string + background: string + onBackground: string + surface: string + onSurface: string + surfaceVariant: string + onSurfaceVariant: string + outline: string + outlineVariant: string + error: string + onError: string + errorContainer: string + onErrorContainer: string +} + +/** + * Type guard — returns true when the runtime env is an iOS-platform env. + * + * @example + * if (isIosEnv(env)) { + * // env.widgetRenderingMode is narrowed to the concrete value (not undefined) + * } + */ +export function isIosEnv( + env: WidgetEnvironment +): env is WidgetEnvironment & { widgetRenderingMode: NonNullable } { + return env.widgetRenderingMode !== undefined +} + +/** + * Type guard — returns true when the runtime env is an Android-platform env. + */ +export function isAndroidEnv( + env: WidgetEnvironment +): env is WidgetEnvironment & { materialColors: NonNullable } { + return env.materialColors !== undefined +} diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index ad57c946..b857ed23 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -11,5 +11,5 @@ export { resolveFontPaths } from './utils/fonts' export { normalizeLocaleTag, pickLocalizedValue } from './utils/localePick' export { logger } from './utils/logger' export type { PrerenderableWidget, PrerenderedWidgetStates, WidgetRenderer } from './utils/prerender' -export { prerenderWidgetState } from './utils/prerender' +export { evaluateWidgetModule, prerenderWidgetState } from './utils/prerender' export { isWidgetLocalizedMap, widgetLabelEnglish } from './utils/widgetLabel' diff --git a/packages/expo-plugin/src/utils/prerender.ts b/packages/expo-plugin/src/utils/prerender.ts index 828e0c01..9727e7a9 100644 --- a/packages/expo-plugin/src/utils/prerender.ts +++ b/packages/expo-plugin/src/utils/prerender.ts @@ -96,8 +96,13 @@ function transpileFile(filePath: string, projectRoot: string): string { * Evaluate a widget module using Babel transpilation and Node.js VM. * This allows executing widget code that uses JSX and React components. * Local module dependencies are also transpiled with the same Babel settings. + * + * Exported so platform-specific prerender flows (e.g. the client-rendered widget prerender + * in @use-voltra/ios-client) can reuse the same module loader rather than duplicating the + * Babel + VM scaffolding. The returned value is the module's exports object — callers + * decide whether to read `.default`, a named export, etc. */ -function evaluateWidgetModule(projectRoot: string, filePath: string): any { +export function evaluateWidgetModule(projectRoot: string, filePath: string): any { // Cache for already-evaluated modules to handle circular dependencies const moduleCache = new Map() diff --git a/packages/ios-client/README.md b/packages/ios-client/README.md index f636a4bc..6e172925 100644 --- a/packages/ios-client/README.md +++ b/packages/ios-client/README.md @@ -12,6 +12,8 @@ - **iOS Widgets**: Update, schedule, reload, and query widgets with `updateWidget`, `scheduleWidget`, `getActiveWidgets`, and more. +- **Client-rendered widgets** _(experimental)_: Write a widget as a `'use voltra'` JSX component and have it render on-device from its own JS bundle, with live env (family, color scheme, locale, configuration). See the note below. + - **Fast Refresh**: Hooks and previews integrate with your React Native dev workflow. - **Push & events**: Capture ActivityKit push tokens and component interactions via `addVoltraListener`. @@ -20,6 +22,25 @@ - **Expo config plugin**: Add `"@use-voltra/ios-client"` to `app.json` to generate the Live Activity extension, widget targets, and entitlements. +## Client-rendered widgets (experimental) + +> [!WARNING] +> Client-rendered widgets are **experimental** — usable in production at your own risk. The API +> and generated build output may change between releases. + +A widget whose component carries the `'use voltra'` directive is rendered **on-device**: its JS +bundle is evaluated in a separate engine on each render and called as `(props, env) => JSX`, so the +widget reacts to live environment values (widget family, color scheme, locale, and user +`configuration` from the native Edit Widget sheet). In development the bundle is served by Metro +(editing the JSX hot-reloads the home-screen widget); in release builds it is baked into the widget +extension at build time. + +Notes: + +- The dev loop and release baking rely on Metro scaffolding in your project (see `example/metro`). +- Verify release rendering on a **real device** — the iOS Simulator is unreliable for widget + rendering. + ## Documentation The documentation is available at [use-voltra.dev](https://use-voltra.dev). Relevant topics for this package: diff --git a/packages/ios-client/expo-plugin/jest.config.js b/packages/ios-client/expo-plugin/jest.config.js index 5835d68b..4e24b4de 100644 --- a/packages/ios-client/expo-plugin/jest.config.js +++ b/packages/ios-client/expo-plugin/jest.config.js @@ -4,8 +4,10 @@ module.exports = { testMatch: ['/src/**/*.node.test.ts'], modulePathIgnorePatterns: ['/build'], moduleNameMapper: { + '^@use-voltra/compiler$': '/../../compiler/src/index.ts', '^@use-voltra/expo-plugin$': '/../../expo-plugin/src/index.ts', '^@use-voltra/expo-plugin/(.*)$': '/../../expo-plugin/src/$1', + '^@use-voltra/metro/scanner$': '/../../metro/src/scanner.ts', }, transform: { '^.+\\.tsx?$': [ diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.node.test.ts new file mode 100644 index 00000000..57432f06 --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.node.test.ts @@ -0,0 +1,206 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import type { IOSWidgetConfig } from '../types' + +import { detectClientRenderedWidgets } from './clientRendered' + +function makeTempProject(files: Record): { projectRoot: string; cleanup: () => void } { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-client-rendered-test-')) + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(projectRoot, rel) + fs.mkdirSync(path.dirname(abs), { recursive: true }) + fs.writeFileSync(abs, content) + } + return { + projectRoot, + cleanup: () => fs.rmSync(projectRoot, { recursive: true, force: true }), + } +} + +function asWidget(partial: Partial): IOSWidgetConfig { + return { + id: 'placeholder', + displayName: 'Placeholder', + description: 'Placeholder', + ...partial, + } +} + +// Detection emits a one-time EXPERIMENTAL console.warn; keep it out of test output (the dedicated +// suite below asserts on it explicitly). +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('detectClientRenderedWidgets', () => { + it('flags an arrow-function export with use voltra directive as client-rendered when id matches', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Foo.tsx': ` + export const Foo = (props, env) => { + 'use voltra' + return null + } + `, + }) + try { + const [detected] = detectClientRenderedWidgets( + [asWidget({ id: 'Foo', initialStatePath: './widgets/Foo.tsx' })], + projectRoot + ) + expect(detected.clientRendered).toBe(true) + if (detected.clientRendered) { + expect(detected.clientComponentName).toBe('Foo') + expect(detected.clientSourcePath).toBe(path.join(projectRoot, 'widgets/Foo.tsx')) + } + } finally { + cleanup() + } + }) + + it('flags a function declaration export with use voltra directive as client-rendered', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Bar.tsx': ` + export function Bar(props, env) { + 'use voltra' + return null + } + `, + }) + try { + const [detected] = detectClientRenderedWidgets( + [asWidget({ id: 'Bar', initialStatePath: './widgets/Bar.tsx' })], + projectRoot + ) + expect(detected.clientRendered).toBe(true) + } finally { + cleanup() + } + }) + + it('returns server-rendered for files without the directive', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Plain.tsx': ` + export const Plain = () => null + `, + }) + try { + const [detected] = detectClientRenderedWidgets( + [asWidget({ id: 'Plain', initialStatePath: './widgets/Plain.tsx' })], + projectRoot + ) + expect(detected.clientRendered).toBe(false) + } finally { + cleanup() + } + }) + + it('returns server-rendered when initialStatePath is missing', () => { + const { projectRoot, cleanup } = makeTempProject({}) + try { + const [detected] = detectClientRenderedWidgets([asWidget({ id: 'NoPath' })], projectRoot) + expect(detected.clientRendered).toBe(false) + } finally { + cleanup() + } + }) + + it('throws when widget id does not match the use voltra component name', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Real.tsx': ` + export const RealName = () => { + 'use voltra' + return null + } + `, + }) + try { + expect(() => + detectClientRenderedWidgets( + [asWidget({ id: 'WrongName', initialStatePath: './widgets/Real.tsx' })], + projectRoot + ) + ).toThrow(/Widget id mismatch/) + } finally { + cleanup() + } + }) + + it('accepts a localized initialStatePath map (uses the first available locale)', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Localized.tsx': ` + export const Localized = () => { + 'use voltra' + return null + } + `, + }) + try { + const [detected] = detectClientRenderedWidgets( + [ + asWidget({ + id: 'Localized', + initialStatePath: { en: './widgets/Localized.tsx', pl: './widgets/Localized.tsx' }, + }), + ], + projectRoot + ) + expect(detected.clientRendered).toBe(true) + } finally { + cleanup() + } + }) +}) + +describe('detectClientRenderedWidgets — experimental warning', () => { + // The warning fires at most once per module instance, so re-require a fresh module per test. + function freshDetect(): typeof detectClientRenderedWidgets { + let fn: typeof detectClientRenderedWidgets = detectClientRenderedWidgets + jest.isolateModules(() => { + fn = require('./clientRendered').detectClientRenderedWidgets + }) + return fn + } + + it('warns once (with EXPERIMENTAL + the widget id) when a client-rendered widget is detected', () => { + const detect = freshDetect() + const warn = console.warn as jest.Mock + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Foo.tsx': ` + export const Foo = (props, env) => { + 'use voltra' + return null + } + `, + }) + try { + const widgets = [asWidget({ id: 'Foo', initialStatePath: './widgets/Foo.tsx' })] + detect(widgets, projectRoot) + detect(widgets, projectRoot) // second detection in the same process must not re-warn + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toContain('EXPERIMENTAL') + expect(warn.mock.calls[0][0]).toContain('Foo') + } finally { + cleanup() + } + }) + + it('does not warn when all widgets are server-rendered', () => { + const detect = freshDetect() + const warn = console.warn as jest.Mock + const { projectRoot, cleanup } = makeTempProject({ + 'widgets/Bar.tsx': 'export const Bar = () => null\n', + }) + try { + detect([asWidget({ id: 'Bar', initialStatePath: './widgets/Bar.tsx' })], projectRoot) + expect(warn).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts new file mode 100644 index 00000000..385f9bb0 --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRendered.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs' +import * as path from 'path' + +import { isWidgetLocalizedMap, type WidgetInitialStatePath } from '@use-voltra/expo-plugin' +import { scanVoltraDirectives } from '@use-voltra/compiler' + +import type { IOSWidgetConfig } from '../types' + +/** + * Client-rendered widget detection. + * + * Voltra supports two widget rendering paths: + * + * - **Server-rendered** (the original path): the host app pushes JSON state to the widget + * extension over IPC; the widget extension renders Voltra primitives from that JSON. + * - **Client-rendered**: the widget extension downloads a JS bundle from Metro (dev) or + * reads a baked bundle (prod), evaluates it in JSC, and calls a `(props, env) => JSX` + * function on every render. The app.json schema is unified between the two — client-rendered + * widgets are detected **implicitly** by inspecting the JSX file referenced by + * `initialStatePath` for a `'use voltra'` directive (Babel's "use strict"-style directive + * prologue) inside an exported function whose identifier matches the widget's `id`. + * + * The widget `id` in app.json **must** equal the JSX component name. If `'use voltra'` is + * present but no exported function with that exact name exists, the plugin fails loudly at + * prebuild — a silent fallback would let drift between the Metro bundle URL (uses the + * component name) and Swift's `kind` string (uses the app.json id). + * + * Footgun: removing `'use voltra'` from the JSX silently switches the widget back to + * server-rendered mode. A future improvement would be an explicit + * `renderMode: 'server' | 'client'` flag. + */ + +/** + * Widget config augmented with the prebuild-time derived rendering mode. + * + * `clientRendered: false` is exactly the existing path (no behavior change for server widgets). + * `clientRendered: true` adds `clientComponentName` (always equal to `id` per id-matching + * validation) and `clientSourcePath` (absolute path to the JSX file, used by the prerender + * step and by the generated Swift Provider's Metro URL — its `.bundle` suffix is the + * same as the component name). + */ +export type DetectedIOSWidget = + | (IOSWidgetConfig & { clientRendered: false }) + | (IOSWidgetConfig & { + clientRendered: true + clientComponentName: string + clientSourcePath: string + }) + +// Emit the experimental notice at most once per prebuild process (detection runs from several +// plugin steps). +let hasWarnedExperimental = false + +/** + * Inspect every widget's `initialStatePath` source file once and tag each entry as either + * server- or client-rendered. Throws on mismatch between `'use voltra'`-tagged component + * name and widget `id`. + */ +export function detectClientRenderedWidgets(widgets: IOSWidgetConfig[], projectRoot: string): DetectedIOSWidget[] { + const detected = widgets.map((widget) => detectSingleWidget(widget, projectRoot)) + + if (!hasWarnedExperimental) { + const clientWidgetIds = detected.filter((widget) => widget.clientRendered).map((widget) => widget.id) + if (clientWidgetIds.length > 0) { + hasWarnedExperimental = true + console.warn( + `[voltra] Client-rendered widgets are EXPERIMENTAL (${clientWidgetIds.join(', ')}). ` + + 'The widget JSX runs on-device in a separate JS engine; the API and build output may change, ' + + 'and production rendering should be verified on a real device. Use at your own risk.' + ) + } + } + + return detected +} + +function detectSingleWidget(widget: IOSWidgetConfig, projectRoot: string): DetectedIOSWidget { + if (!widget.initialStatePath) { + return { ...widget, clientRendered: false } + } + + const sourcePath = resolveAnyInitialStatePath(widget.initialStatePath, projectRoot) + if (!sourcePath || !fs.existsSync(sourcePath)) { + return { ...widget, clientRendered: false } + } + + const source = fs.readFileSync(sourcePath, 'utf8') + + // Cheap pre-check — Babel parsing is not free, and the directive's literal string is + // tiny and unambiguous. Same shortcut the Metro scanner uses + // (example/metro/scanVoltraDirectives.js). + if (!source.includes("'use voltra'") && !source.includes('"use voltra"')) { + return { ...widget, clientRendered: false } + } + + const directiveWidgets = scanVoltraDirectives({ filePath: sourcePath, source }) + if (directiveWidgets.length === 0) { + // Directive string is present somewhere (comment, embedded literal), but no exported + // function carries it as a directive. Treat as server-rendered; if the user meant + // client-rendered they'll notice when the widget keeps using server payloads. + return { ...widget, clientRendered: false } + } + + const directiveWidget = directiveWidgets.find((candidate) => candidate.id === widget.id) + if (!directiveWidget) { + const componentName = directiveWidgets[0].componentName + throw new Error( + `[voltra] Widget id mismatch: widget "${widget.id}" in app.json has initialStatePath ` + + `pointing at ${path.relative(projectRoot, sourcePath)} but that file's 'use voltra' ` + + `directive belongs to component "${componentName}". For client-rendered widgets the ` + + `app.json id and the JSX component name must match exactly (the id becomes both the ` + + `Metro bundle URL suffix and the WidgetKit "kind" string — they cannot diverge).` + ) + } + + return { + ...widget, + clientRendered: true, + clientComponentName: directiveWidget.componentName, + clientSourcePath: sourcePath, + } +} + +function resolveAnyInitialStatePath(spec: WidgetInitialStatePath, projectRoot: string): string | null { + if (typeof spec === 'string') { + return path.resolve(projectRoot, spec) + } + if (isWidgetLocalizedMap(spec)) { + const firstPath = Object.values(spec).find( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + return firstPath ? path.resolve(projectRoot, firstPath) : null + } + return null +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts new file mode 100644 index 00000000..25dd6955 --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/clientRenderedPrerender.ts @@ -0,0 +1,107 @@ +import { evaluateWidgetModule, logger, type PrerenderedWidgetStates } from '@use-voltra/expo-plugin' + +import type { DetectedIOSWidget } from './clientRendered' + +/** + * Initial-state prerender for client-rendered widgets. + * + * For server-rendered widgets, the existing `prerenderWidgetState` in @use-voltra/expo-plugin + * loads the file at `initialStatePath`, reads `exports.default` (a `WidgetVariants` object), + * and runs it through the multi-family `renderWidgetToString` to produce a per-family JSON + * payload that the runtime's `selectContentForFamily` will pick from. + * + * Client-rendered widgets work differently: the file exports a function + * `(props, env) => JSX` (tagged with `'use voltra'`), and the runtime calls it per-render + * with real env values. For the WidgetKit placeholder (`Provider.placeholder` and the + * widget gallery preview) ONE pre-rendered JSON entry is needed to display before any + * Metro fetch completes. That's generated here by calling the same function at prebuild + * with empty props + a minimal env, storing the compact `{t, c, p}` JSON in the existing + * `voltra_initial_states.json`. + * + * The placeholder's env values are fixed (`widgetFamily: 'systemMedium'`, + * `colorScheme: 'light'`, etc.) and may not match what the user actually sees the moment + * they add the widget — but the first real timeline tick replaces this entry within + * milliseconds, so the brief mismatch is invisible in practice. + */ + +/** Locale key used for non-localized prerendered states (mirrors prerender.ts behavior). */ +const SINGLE_LOCALE_KEY = '__default' + +/** + * Default env passed to client widgets at prebuild for the placeholder render. Fields + * mirror `WidgetEnvironment` from packages/core/src/widget-environment.ts so the widget + * function sees the same shape it gets at runtime. + */ +function buildPlaceholderEnv(): Record { + return { + date: Date.now(), + widgetFamily: 'systemMedium', + colorScheme: 'light', + locale: 'en-US', + widgetRenderingMode: 'fullColor', + showsWidgetContainerBackground: true, + configuration: undefined, + build: { + isDev: false, + metroUrl: null, + appVersion: 'unknown', + voltraVersion: '1.4.1', + }, + } +} + +/** + * Prerender placeholder JSON for every client-rendered widget. Returns a map shaped + * identically to `prerenderWidgetState`'s output so the two can be merged before + * generating `VoltraWidgetInitialStates.swift`. + */ +export async function prerenderClientRenderedWidgets( + widgets: DetectedIOSWidget[], + projectRoot: string +): Promise { + const results: PrerenderedWidgetStates = new Map() + + const clientWidgets = widgets.filter( + (w): w is Extract => w.clientRendered + ) + if (clientWidgets.length === 0) { + return results + } + + // Lazy-loaded so server-only projects never pull in @use-voltra/ios. The renderer + // is iOS-specific by design — the Android counterpart will use the same shape via + // @use-voltra/android in a future phase. + const iosModuleId = '@use-voltra/ios' + const { renderVoltraVariantToJson } = (await import(iosModuleId)) as { + renderVoltraVariantToJson: (element: unknown) => unknown + } + + const placeholderEnv = buildPlaceholderEnv() + + for (const widget of clientWidgets) { + try { + const exports = evaluateWidgetModule(projectRoot, widget.clientSourcePath) + const widgetFn = exports[widget.clientComponentName] + if (typeof widgetFn !== 'function') { + throw new Error( + `Expected the file to export a function named "${widget.clientComponentName}" ` + + `(the widget id from app.json). Found: ${Object.keys(exports).join(', ') || '(no named exports)'}` + ) + } + + const element = widgetFn({}, placeholderEnv) + const json = renderVoltraVariantToJson(element) + const jsonString = JSON.stringify(json) + results.set(widget.id, new Map([[SINGLE_LOCALE_KEY, jsonString]])) + } catch (error) { + throw new Error( + `Failed to prerender client-rendered widget "${widget.id}": ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + } + + logger.info(`Prerendered ${clientWidgets.length} client-rendered widget placeholder(s)`) + return results +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 8fa35bd7..88bcb575 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -1,3 +1,5 @@ +import type { DetectedIOSWidget } from '../clientRendered' + import { __test__ } from './swift' describe('generateInitialStatesSwift', () => { @@ -20,3 +22,101 @@ describe('generateInitialStatesSwift', () => { expect(swift).not.toContain('VoltraInitialStateLocale.preferredLanguageTags()') }) }) + +describe('generateWidgetBundleSwift — client-rendered dispatch', () => { + const serverWidget: DetectedIOSWidget = { + id: 'weather', + displayName: 'Weather', + description: 'Shows weather', + clientRendered: false, + } + const clientWidget: DetectedIOSWidget = { + id: 'IosWeatherWidget', + displayName: 'Client Weather', + description: 'Client-rendered weather', + clientRendered: true, + clientComponentName: 'IosWeatherWidget', + clientSourcePath: '/tmp/IosWeatherWidget.tsx', + } + + it('emits VoltraHomeWidgetProvider for server-rendered widgets', () => { + const swift = __test__.generateWidgetBundleSwift([serverWidget]) + expect(swift).toContain('VoltraHomeWidgetProvider(') + expect(swift).toContain('VoltraHomeWidgetView(entry: entry)') + expect(swift).not.toContain('VoltraClientWidgetProvider') + }) + + it('emits VoltraClientWidgetProvider + VoltraClientWidgetContentView for client-rendered widgets', () => { + const swift = __test__.generateWidgetBundleSwift([clientWidget]) + expect(swift).toContain('VoltraClientWidgetProvider(') + expect(swift).toContain('VoltraClientWidgetContentView(') + expect(swift).toContain('initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)') + expect(swift).not.toContain('VoltraHomeWidgetProvider(') + }) + + it('handles mixed server + client widgets in one bundle', () => { + const swift = __test__.generateWidgetBundleSwift([serverWidget, clientWidget]) + expect(swift).toContain('VoltraWidget_weather()') + expect(swift).toContain('VoltraWidget_IosWeatherWidget()') + expect(swift).toContain('VoltraHomeWidgetProvider(') + expect(swift).toContain('VoltraClientWidgetProvider(') + }) + + it('keeps WidgetKit kind, supportedFamilies, contentMarginsDisabled identical across modes', () => { + const serverSwift = __test__.generateWidgetBundleSwift([serverWidget]) + const clientSwift = __test__.generateWidgetBundleSwift([clientWidget]) + for (const swift of [serverSwift, clientSwift]) { + expect(swift).toMatch(/StaticConfiguration\(\s*\n\s*kind: "Voltra_Widget_/) + expect(swift).toContain('.supportedFamilies(') + expect(swift).toContain('.contentMarginsDisabled()') + } + }) +}) + +describe('generateWidgetBundleSwift — AppIntent configuration', () => { + const configurableWidget: DetectedIOSWidget = { + id: 'IosWeatherWidget', + displayName: 'Client Weather', + description: 'Client-rendered weather', + clientRendered: true, + clientComponentName: 'IosWeatherWidget', + clientSourcePath: '/tmp/IosWeatherWidget.tsx', + appIntent: { + parameters: [{ name: 'label', title: 'Label', default: 'Hello' }], + }, + } + const plainClientWidget: DetectedIOSWidget = { + id: 'PlainClient', + displayName: 'Plain Client', + description: 'Client-rendered, no config', + clientRendered: true, + clientComponentName: 'PlainClient', + clientSourcePath: '/tmp/PlainClient.tsx', + } + + it('generates an AppIntentConfiguration with a code-default @Parameter for a configurable widget', () => { + const swift = __test__.generateWidgetBundleSwift([configurableWidget]) + expect(swift).toContain('import AppIntents') + expect(swift).toContain('struct VoltraWidget_IosWeatherWidget_Intent: WidgetConfigurationIntent') + expect(swift).toContain('@Parameter(title: "Label", default: "Hello")') + expect(swift).toContain('AppIntentConfiguration(') + expect(swift).toContain('VoltraWidget_IosWeatherWidget_ClientProvider') + // iOS 17+ gating, since AppIntentConfiguration is unavailable below 17 + expect(swift).toContain('if #available(iOS 17.0, *)') + expect(swift).toContain('@available(iOS 17.0, *)') + }) + + it('passes the configured parameter into the entry (env.configuration)', () => { + const swift = __test__.generateWidgetBundleSwift([configurableWidget]) + expect(swift).toContain('VoltraClientWidgetProvider.loadEntry(widgetId:') + expect(swift).toContain('["label": configuration.label]') + }) + + it('does NOT emit AppIntent code for a client widget without appIntent', () => { + const swift = __test__.generateWidgetBundleSwift([plainClientWidget]) + expect(swift).toContain('VoltraClientWidgetProvider(') + expect(swift).not.toContain('AppIntentConfiguration(') + expect(swift).not.toContain('WidgetConfigurationIntent') + expect(swift).not.toContain('import AppIntents') + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts index 2fef4df8..701cf217 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.ts @@ -14,6 +14,8 @@ import { import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' import type { IOSWidgetConfig } from '../../types' import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' +import { detectClientRenderedWidgets, type DetectedIOSWidget } from '../clientRendered' +import { prerenderClientRenderedWidgets } from '../clientRenderedPrerender' export interface GenerateSwiftFilesOptions { targetPath: string @@ -43,8 +45,24 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr renderWidgetToString: RenderWidgetToString } - // Prerender widget initial states if any widgets have initialStatePath configured - const prerenderedStates = await prerenderWidgetState(widgets || [], projectRoot, renderWidgetToString) + // Tag each widget with its rendering mode (server vs client) by inspecting the + // initialStatePath JSX for a 'use voltra' directive. See ../clientRendered.ts. + // Throws on widget id / component name mismatch. + const detectedWidgets = detectClientRenderedWidgets(widgets || [], projectRoot) + const clientWidgetCount = detectedWidgets.filter((w) => w.clientRendered).length + if (clientWidgetCount > 0) { + logger.info(`Detected ${clientWidgetCount} client-rendered widget(s) — generating Provider scaffolding`) + } + + // Prerender widget initial states. Server-rendered widgets go through the existing + // multi-family WidgetVariants → JSON path; client-rendered widgets go through the + // client-rendered path (call the 'use voltra' function with default props + minimal env, + // run renderVoltraVariantToJson, stringify). Both produce entries in the same map shape + // so VoltraWidgetInitialStates.swift can read either via the same lookup at runtime. + const serverWidgets = detectedWidgets.filter((w) => !w.clientRendered) + const serverStates = await prerenderWidgetState(serverWidgets, projectRoot, renderWidgetToString) + const clientStates = await prerenderClientRenderedWidgets(detectedWidgets, projectRoot) + const prerenderedStates = new Map([...serverStates, ...clientStates]) syncVoltraWidgetGalleryStrings(targetPath, widgets) @@ -59,7 +77,7 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr // Generate the widget bundle Swift file const widgetBundleContent = - widgets && widgets.length > 0 ? generateWidgetBundleSwift(widgets) : generateDefaultWidgetBundleSwift() + detectedWidgets.length > 0 ? generateWidgetBundleSwift(detectedWidgets) : generateDefaultWidgetBundleSwift() const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift') fs.writeFileSync(widgetBundlePath, widgetBundleContent) @@ -263,9 +281,23 @@ function iosWidgetGalleryLabelSwiftExpr( } /** - * Generates Swift code for a single widget struct + * Generates Swift code for a single widget struct. Dispatches on rendering mode: + * - server-rendered → `VoltraHomeWidgetProvider` + `VoltraHomeWidgetView` (existing path) + * - client-rendered → `VoltraClientWidgetProvider` + `VoltraClientWidgetContentView` + * (the content view internally renders via VoltraHomeWidgetView so the UI layer is + * identical to server-rendered widgets — see VoltraClientWidgetRuntime.swift) */ -function generateWidgetStruct(widget: IOSWidgetConfig): string { +function widgetUsesAppIntent(widget: DetectedIOSWidget): boolean { + return widget.clientRendered && !!widget.appIntent && widget.appIntent.parameters.length > 0 +} + +function generateWidgetStruct(widget: DetectedIOSWidget): string { + // Client-rendered widgets with an appIntent config get a native "Edit Widget" sheet via + // AppIntentConfiguration; the configured params flow into env.configuration. + if (widgetUsesAppIntent(widget)) { + return generateClientAppIntentWidgetCode(widget) + } + const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ') @@ -275,6 +307,27 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string { const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName) const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description) + const providerAndContent = widget.clientRendered + ? dedent` + provider: VoltraClientWidgetProvider( + widgetId: widgetId, + initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) + ) + ) { entry in + VoltraClientWidgetContentView( + entry: entry, + initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) + ) + }` + : dedent` + provider: VoltraHomeWidgetProvider( + widgetId: widgetId, + initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) + ) + ) { entry in + VoltraHomeWidgetView(entry: entry) + }` + return dedent` public struct ${structName}: Widget { private let widgetId = "${widget.id}" @@ -284,12 +337,100 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string { public var body: some WidgetConfiguration { StaticConfiguration( kind: "Voltra_Widget_${widget.id}", - provider: VoltraHomeWidgetProvider( - widgetId: widgetId, + ${providerAndContent} + .configurationDisplayName(${displayNameExpr}) + .description(${descriptionExpr}) + .supportedFamilies([${familiesSwift}]) + .contentMarginsDisabled() + } + } + ` +} + +/** + * Generates a client-rendered widget backed by AppIntentConfiguration (iOS 17+): a + * WidgetConfigurationIntent (params + code defaults), an AppIntentTimelineProvider that loads the + * bundle via the shared client runtime, and an AppIntentConfiguration widget. The configured + * params are passed into the render as env.configuration; the native "Edit Widget" sheet edits them. + */ +function generateClientAppIntentWidgetCode(widget: DetectedIOSWidget): string { + const params = widget.appIntent!.parameters + const families = widget.supportedFamilies ?? DEFAULT_WIDGET_FAMILIES + const familiesSwift = families.map((f) => WIDGET_FAMILY_MAP[f]).join(', ') + const intentName = `VoltraWidget_${widget.id}_Intent` + const providerName = `VoltraWidget_${widget.id}_ClientProvider` + const intentTitle = escapeForSwiftStringLiteral(`Configure ${widgetLabelEnglish(widget.displayName)}`) + const displayNameExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'displayName', widget.displayName) + const descriptionExpr = iosWidgetGalleryLabelSwiftExpr(widget.id, 'description', widget.description) + + const swiftDefault = (p: { default?: string }) => `"${escapeForSwiftStringLiteral(p.default ?? '')}"` + const dictLiteral = (entries: string[]) => (entries.length > 0 ? `[${entries.join(', ')}]` : '[:]') + + const paramDecls = params + .map( + (p) => + ` @Parameter(title: "${escapeForSwiftStringLiteral(p.title)}", default: ${swiftDefault(p)})\n var ${ + p.name + }: String` + ) + .join('\n\n') + const initParams = params.map((p) => `${p.name}: String`).join(', ') + const initBody = params.map((p) => ` self.${p.name} = ${p.name}`).join('\n') + const configuredDict = dictLiteral(params.map((p) => `"${p.name}": configuration.${p.name}`)) + const defaultDict = dictLiteral(params.map((p) => `"${p.name}": ${swiftDefault(p)}`)) + + return dedent` + // MARK: - Client-rendered AppIntent widget: ${widget.id} + + @available(iOS 17.0, *) + struct ${intentName}: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "${intentTitle}" + + ${paramDecls} + + init() {} + init(${initParams}) { + ${initBody} + } + } + + @available(iOS 17.0, *) + private struct ${providerName}: AppIntentTimelineProvider { + typealias Intent = ${intentName} + typealias Entry = VoltraClientWidgetEntry + + private let widgetId = "${widget.id}" + + func placeholder(in _: Context) -> VoltraClientWidgetEntry { + VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false, configuration: ${defaultDict}) + } + + func snapshot(for configuration: ${intentName}, in _: Context) async -> VoltraClientWidgetEntry { + await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict}) + } + + func timeline(for configuration: ${intentName}, in _: Context) async -> Timeline { + let entry = await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: ${configuredDict}) + return Timeline(entries: [entry], policy: .never) + } + } + + @available(iOS 17.0, *) + public struct VoltraWidget_${widget.id}: Widget { + private let widgetId = "${widget.id}" + + public init() {} + + public var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "Voltra_Widget_${widget.id}", + intent: ${intentName}.self, + provider: ${providerName}() + ) { entry in + VoltraClientWidgetContentView( + entry: entry, initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId) ) - ) { entry in - VoltraHomeWidgetView(entry: entry) } .configurationDisplayName(${displayNameExpr}) .description(${descriptionExpr}) @@ -303,15 +444,25 @@ function generateWidgetStruct(widget: IOSWidgetConfig): string { /** * Generates the VoltraWidgetBundle.swift file content with configured widgets */ -function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string { +function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { // Generate widget structs - const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') - - // Generate widget bundle body entries - const widgetInstances = widgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + const widgetStructs = widgets.map((w) => generateWidgetStruct(w)).join('\n\n') + + // AppIntent widgets are iOS 17+, so their bundle entries are gated behind #available. + const appIntentWidgets = widgets.filter(widgetUsesAppIntent) + const plainWidgets = widgets.filter((w) => !widgetUsesAppIntent(w)) + const plainInstances = plainWidgets.map((w) => `VoltraWidget_${w.id}()`).join('\n ') + const appIntentInstances = + appIntentWidgets.length > 0 + ? `if #available(iOS 17.0, *) {\n ${appIntentWidgets + .map((w) => `VoltraWidget_${w.id}()`) + .join('\n ')}\n }` + : '' + const widgetInstances = [plainInstances, appIntentInstances].filter(Boolean).join('\n ') const needsFoundation = widgets.some(widgetUsesGalleryLocalization) const foundationImport = needsFoundation ? 'import Foundation\n' : '' + const appIntentsImport = appIntentWidgets.length > 0 ? 'import AppIntents\n' : '' return dedent` // @@ -321,7 +472,7 @@ function generateWidgetBundleSwift(widgets: IOSWidgetConfig[]): string { // This file defines which Voltra widgets are available in your app. // - ${foundationImport}import SwiftUI + ${foundationImport}${appIntentsImport}import SwiftUI import WidgetKit import VoltraWidget @@ -473,4 +624,5 @@ function getSwiftRawStringDelimiter(str: string): string { export const __test__ = { generateInitialStatesSwift, + generateWidgetBundleSwift, } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/index.ts index f372bdb6..67652dc0 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/index.ts @@ -54,7 +54,7 @@ export const withIOS: ConfigPlugin = (config, props) => { ...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin, any]] : []), // 2. Configure Xcode project (creates the target - must run before fonts mod executes) - [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget }], + [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget, widgets }], // 3. Configure Podfile for widget extension target [configurePodfile, { targetName }], diff --git a/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts b/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts index 7494f885..c8bba9bc 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/widgetPlist.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs' import { join as joinPath } from 'path' import type { IOSWidgetConfig } from '../types' +import { detectClientRenderedWidgets } from './clientRendered' import { logger } from '@use-voltra/expo-plugin' export interface ConfigureMainAppPlistProps { @@ -46,6 +47,27 @@ export const configureWidgetExtensionPlist: ConfigPlugin 0) { + const detected = detectClientRenderedWidgets(widgets, config.modRequest.projectRoot) + const hasClientWidget = detected.some((w) => w.clientRendered) + if (hasClientWidget) { + ;(content as any)['NSAppTransportSecurity'] = { + NSAllowsLocalNetworking: true, + NSExceptionDomains: { + localhost: { + NSExceptionAllowsInsecureHTTPLoads: true, + NSIncludesSubdomains: true, + }, + }, + } + } + } + // WidgetKit extensions must NOT declare NSExtensionPrincipalClass/MainStoryboard. // The @main WidgetBundle in Swift is the entry point. const ext = (content as any).NSExtension as Record | undefined diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts new file mode 100644 index 00000000..542f990f --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts @@ -0,0 +1,68 @@ +import { ensureWidgetBundleScriptPhase } from './buildPhases' + +// The xcode lib normally parses a .pbxproj from disk; for a unit test we build the minimal hash +// the real methods touch (native target + the file/build-file sections addBuildPhase reads) and +// exercise the actual library code rather than a mock. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const xcode = require('xcode') + +const TARGET_UUID = 'A'.repeat(24) + +function makeProject() { + const project = xcode.project('/noop.pbxproj') + project.hash = { + project: { + objects: { + PBXNativeTarget: { [TARGET_UUID]: { buildPhases: [] } }, + PBXFileReference: {}, + PBXBuildFile: {}, + }, + }, + } + return project +} + +function shellPhaseObjects(project: any): any[] { + const section = project.hash.project.objects.PBXShellScriptBuildPhase || {} + return Object.keys(section) + .filter((key) => !/_comment$/.test(key)) + .map((key) => section[key]) +} + +describe('ensureWidgetBundleScriptPhase', () => { + it('adds a release-only widget-bundling shell phase to the target', () => { + const project = makeProject() + ensureWidgetBundleScriptPhase(project, TARGET_UUID) + + const phases = shellPhaseObjects(project) + expect(phases).toHaveLength(1) + + const phase = phases[0] + expect(phase.name).toContain('Bundle Voltra client widgets') + expect(phase.shellScript).toContain('@use-voltra/metro/bundle-widgets') + // Debug builds use Metro, so the script must skip them... + expect(phase.shellScript).toContain('Debug') + // ...and bake into the extension's resources dir in release. + expect(phase.shellScript).toContain('UNLOCALIZED_RESOURCES_FOLDER_PATH') + // Re-bakes every release build (can't enumerate widget-source inputs) without warning. + expect(phase.alwaysOutOfDate).toBe(1) + + // Phase is attached to the (extension) target. + expect(project.hash.project.objects.PBXNativeTarget[TARGET_UUID].buildPhases).toHaveLength(1) + }) + + it('is idempotent — re-running does not add a second phase', () => { + const project = makeProject() + ensureWidgetBundleScriptPhase(project, TARGET_UUID) + ensureWidgetBundleScriptPhase(project, TARGET_UUID) + + expect(shellPhaseObjects(project)).toHaveLength(1) + expect(project.hash.project.objects.PBXNativeTarget[TARGET_UUID].buildPhases).toHaveLength(1) + }) + + it('does nothing when the target is absent', () => { + const project = makeProject() + ensureWidgetBundleScriptPhase(project, 'B'.repeat(24)) + expect(shellPhaseObjects(project)).toHaveLength(0) + }) +}) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts index da554df2..b3a171b6 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.ts @@ -5,6 +5,97 @@ import type { IOSWidgetExtensionFiles } from '../../types' const pbxFile = require('xcode/lib/pbxFile') +const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra client widgets' + +// Release-only build phase that bakes each client-rendered widget's production JS bundle into the +// extension's resources. Debug builds fetch from Metro (and hot-reload), so this no-ops there. Runs +// the project's widget bundler with the extension's resources dir as the output, so each +// voltra-widget-.bundle lands in the .appex (Bundle.main) where the runtime's release loader +// reads it. SRCROOT is the ios/ dir; the project root is one level up, matching how Expo's main +// "Bundle React Native code and images" phase resolves things. +const WIDGET_BUNDLE_SHELL_SCRIPT = `if [[ "$CONFIGURATION" == *Debug* ]]; then + echo "Voltra: Debug build — client-rendered widgets load from Metro, skipping bundling" + exit 0 +fi + +if [[ -f "$SRCROOT/.xcode.env" ]]; then + source "$SRCROOT/.xcode.env" +fi +if [[ -f "$SRCROOT/.xcode.env.local" ]]; then + source "$SRCROOT/.xcode.env.local" +fi + +export PROJECT_ROOT="\${PROJECT_ROOT:-$SRCROOT/..}" +NODE_BINARY="\${NODE_BINARY:-node}" + +BUNDLER="$("$NODE_BINARY" - "$PROJECT_ROOT" <<'NODE' +const { createRequire } = require('node:module') +const path = require('node:path') + +const projectRoot = process.argv[2] + +try { + const requireFromProject = createRequire(path.join(projectRoot, 'package.json')) + process.stdout.write(requireFromProject.resolve('@use-voltra/metro/bundle-widgets')) +} catch (error) { + console.error( + 'error: Voltra widget bundler could not resolve @use-voltra/metro from ' + + projectRoot + + '. Install @use-voltra/metro in the app project so release widgets can be baked.\\n' + + (error && error.message ? error.message : String(error)) + ) + process.exit(1) +} +NODE +)" +if [[ -z "$BUNDLER" ]]; then + echo "error: Voltra widget bundler resolution returned an empty path." >&2 + exit 1 +fi + +"$NODE_BINARY" "$BUNDLER" --out-dir "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" --platform ios --project-root "$PROJECT_ROOT" +` + +/** + * Adds (idempotently) the release-only shell-script phase that bakes client-rendered widget + * bundles into the extension. Safe to call on every prebuild; only added when absent. + */ +export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, targetUuid: string): void { + const nativeTargets = xcodeProject.pbxNativeTargetSection() + const target = nativeTargets[targetUuid] + if (!target) { + return + } + if (!target.buildPhases) { + target.buildPhases = [] + } + + const shellPhases = xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {} + const quotedName = `"${WIDGET_BUNDLE_PHASE_NAME}"` + const alreadyPresent = target.buildPhases.some((entry: any) => shellPhases[entry.value]?.name === quotedName) + if (alreadyPresent) { + return + } + + xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', WIDGET_BUNDLE_PHASE_NAME, targetUuid, { + shellPath: '/bin/sh', + shellScript: WIDGET_BUNDLE_SHELL_SCRIPT, + }) + + // The phase intentionally re-bakes on every release build (it can't statically enumerate every + // widget source as an input). Mark it always-out-of-date so Xcode doesn't warn about the missing + // input/output dependencies. + const shellPhasesAfter = xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {} + for (const key of Object.keys(shellPhasesAfter)) { + if (/_comment$/.test(key)) { + continue + } + if (shellPhasesAfter[key]?.name === quotedName) { + shellPhasesAfter[key].alwaysOutOfDate = 1 + } + } +} + export interface AddBuildPhasesOptions { targetUuid: string groupName: string diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index 48f47fb6..1165e7ce 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -1,8 +1,10 @@ import { ConfigPlugin, withXcodeProject } from '@expo/config-plugins' import * as path from 'path' +import type { IOSWidgetConfig } from '../../types' import { getIOSWidgetExtensionFiles } from '../../utils/fileDiscovery' -import { addBuildPhases, ensureBuildPhases } from './buildPhases' +import { detectClientRenderedWidgets } from '../clientRendered' +import { addBuildPhases, ensureBuildPhases, ensureWidgetBundleScriptPhase } from './buildPhases' import { addXCConfigurationList, ensureXCConfigurationList } from './configurationList' import { addPbxGroup, ensurePbxGroup } from './groups' import { getMainAppTargetSettings } from './mainAppSettings' @@ -13,6 +15,7 @@ export interface ConfigureXcodeProjectProps { targetName: string bundleIdentifier: string deploymentTarget: string + widgets?: IOSWidgetConfig[] } /** @@ -28,7 +31,7 @@ export interface ConfigureXcodeProjectProps { * This should run after generateWidgetExtensionFiles so the files exist. */ export const configureXcodeProject: ConfigPlugin = (config, props) => { - const { targetName, bundleIdentifier, deploymentTarget } = props + const { targetName, bundleIdentifier, deploymentTarget, widgets } = props return withXcodeProject(config, (config) => { if (config.modRequest.introspect) { @@ -38,6 +41,12 @@ export const configureXcodeProject: ConfigPlugin = ( const xcodeProject = config.modResults const groupName = 'Embed Foundation Extensions' + // The release widget-bundling phase is only needed when a widget is client-rendered (server + // widgets carry no JS bundle). Detect once so both the create and update paths agree. + const hasClientRenderedWidgets = + !!widgets && + detectClientRenderedWidgets(widgets, config.modRequest.projectRoot).some((widget) => widget.clientRendered) + // Check if target already exists const nativeTargets = xcodeProject.pbxNativeTargetSection() const existingTargetKey = xcodeProject.findTargetKey(targetName) @@ -93,6 +102,10 @@ export const configureXcodeProject: ConfigPlugin = ( mainTargetUuid: xcodeProject.getFirstTarget().uuid, }) + if (hasClientRenderedWidgets) { + ensureWidgetBundleScriptPhase(xcodeProject, existingTargetKey) + } + ensurePbxGroup(xcodeProject, { targetName, widgetFiles, @@ -138,6 +151,10 @@ export const configureXcodeProject: ConfigPlugin = ( widgetFiles, }) + if (hasClientRenderedWidgets) { + ensureWidgetBundleScriptPhase(xcodeProject, targetUuid) + } + // Add PBX group addPbxGroup(xcodeProject, { targetName, diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index 26f85ce3..68193167 100644 --- a/packages/ios-client/expo-plugin/src/types.ts +++ b/packages/ios-client/expo-plugin/src/types.ts @@ -14,6 +14,26 @@ export type IOSWidgetFamily = | 'accessoryRectangular' | 'accessoryInline' +/** + * A single user-configurable parameter exposed via AppIntent (the native "Edit Widget" sheet). + */ +export interface AppIntentParameter { + /** Swift property name + the key under `env.configuration`. */ + name: string + /** Label shown in the widget configuration sheet. */ + title: string + /** Default value used before the user configures the widget (the "from code" default). */ + default?: string +} + +/** + * AppIntent configuration for a user-configurable widget (iOS 17+). + */ +export interface IOSWidgetAppIntentConfig { + /** Parameters the user can edit via "Edit Widget"; surfaced as `env.configuration`. */ + parameters: AppIntentParameter[] +} + /** * Configuration for a single iOS home screen widget. */ @@ -28,6 +48,13 @@ export interface IOSWidgetConfig { supportedFamilies?: IOSWidgetFamily[] initialStatePath?: WidgetInitialStatePath serverUpdate?: IOSWidgetServerUpdateConfig + /** + * AppIntent configuration (iOS 17+). When set on a client-rendered widget, the plugin generates + * an `AppIntentConfiguration` so users configure parameters via the native "Edit Widget" sheet; + * defaults come from `parameters[].default`, and the configured values are passed into the + * widget's `env.configuration` on each render. + */ + appIntent?: IOSWidgetAppIntentConfig } /** diff --git a/packages/ios-client/expo-plugin/tsconfig.typecheck.json b/packages/ios-client/expo-plugin/tsconfig.typecheck.json index 58e6f899..eb1a3623 100644 --- a/packages/ios-client/expo-plugin/tsconfig.typecheck.json +++ b/packages/ios-client/expo-plugin/tsconfig.typecheck.json @@ -5,7 +5,9 @@ "rootDir": "../../..", "baseUrl": "../../..", "paths": { - "@use-voltra/expo-plugin": ["packages/expo-plugin/src/index.ts"] + "@use-voltra/compiler": ["packages/compiler/src/index.ts"], + "@use-voltra/expo-plugin": ["packages/expo-plugin/src/index.ts"], + "@use-voltra/metro/scanner": ["packages/metro/src/scanner.ts"] } }, "include": ["./src"] diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index 86ec3b51..c52e8185 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -2,6 +2,7 @@ import ActivityKit import Compression import Foundation import os +import React import UIKit @objc(VoltraHeadlessState) @@ -59,7 +60,26 @@ public class VoltraModuleImpl { public init() { // Clean up data for widgets that are no longer installed VoltraWidgetService.cleanupOrphanedData() - } + #if DEBUG + syncDevServerURL() + #endif + } + + #if DEBUG + /// Resolve the Metro dev-server base URL via React Native's own provider and relay it to the + /// widget extension through the app group. The extension is React-free (can't call + /// RCTBundleURLProvider itself), so the app resolves it and the extension reads it — fixing the + /// case where Metro isn't on localhost:8081 (custom port, LAN dev server, physical device). + private func syncDevServerURL() { + guard + let url = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index"), + let scheme = url.scheme, + let host = url.host + else { return } + let port = url.port.map { ":\($0)" } ?? "" + VoltraWidgetDefaults.setDevServerURL("\(scheme)://\(host)\(port)") + } + #endif func isHeadless() -> Bool { VoltraHeadlessState.shared.isHeadless() @@ -255,6 +275,11 @@ public class VoltraModuleImpl { } func reloadWidgets(widgetIds: [String]?) async { + #if DEBUG + // Refresh the relayed dev-server URL before reloading (hot-reload path) so the extension + // fetches from the current Metro host. + syncDevServerURL() + #endif if let ids = widgetIds, !ids.isEmpty { for widgetId in ids { VoltraWidgetService.reloadTimeline(for: widgetId) diff --git a/packages/ios-client/ios/shared/VoltraConstants.swift b/packages/ios-client/ios/shared/VoltraConstants.swift index e051d591..0d9651a3 100644 --- a/packages/ios-client/ios/shared/VoltraConstants.swift +++ b/packages/ios-client/ios/shared/VoltraConstants.swift @@ -32,6 +32,10 @@ public enum VoltraStorageKeys { public static let widgetKindPrefix = "Voltra_Widget_" + /// Metro dev-server base URL, relayed app → widget extension (DEBUG only). The extension is + /// React-free, so it can't resolve the URL itself; the app writes it via RCTBundleURLProvider. + public static let devServerURL = "Voltra_DevServerURL" + // MARK: - Info.plist keys public static let widgetIds = "Voltra_WidgetIds" diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift new file mode 100644 index 00000000..27c0991b --- /dev/null +++ b/packages/ios-client/ios/shared/VoltraJSRenderer.swift @@ -0,0 +1,201 @@ +import Foundation +import JavaScriptCore + +/// JavaScriptCore-backed runtime for Voltra **client-rendered widgets**. +/// +/// Each widget ships its own Metro bundle that defines `module.exports.render(props, env)` +/// (see `example/metro/widgetRegistry.js`). When evaluated, the bundle ends with `__r(0)` +/// which executes its entry module; the source is wrapped with a small bootstrap that +/// captures those exports into `globalThis.__voltraWidgets[]` so `render` can be +/// called from native at any later moment. +/// +/// One shared `JSContext` per process. Each subsequent bundle evaluation overwrites Metro's +/// `__r`/`__d` globals (they're closure-scoped per bundle's IIFE), but the captured +/// `globalThis.__voltraWidgets[]` reference retains each widget's render function +/// indefinitely. +public enum VoltraJSRenderer { + private static var _context: JSContext? + private static let lock = NSLock() + private static let TAG = "VoltraJSRenderer" + + // MARK: - Public API + + /// Evaluate a Voltra widget bundle in the shared JSContext, capturing the bundle's + /// `render(props, env)` export under `globalThis.__voltraWidgets[]`. + /// + /// The bundle source is the raw output of Metro's + /// `/voltra/widgets/.bundle` endpoint. We append a small bootstrap line + /// that runs after the bundle's `__r(0)` to capture the entry module's exports. + /// + /// Idempotent: re-evaluating the same widget overwrites the captured exports — used + /// by dev-mode hot-reload (always-refetch policy). + public static func evaluateBundle(source: String, widgetId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + + guard let ctx = context() else { + VoltraLogger.widget.error("[\(TAG)] No JSContext available") + return false + } + + let escapedId = jsStringLiteral(widgetId) + // Metro emits the bundle's entry invocation as `__r();` near the + // end of the file (before the sourcemap/sourceURL comments). The entry id is NOT + // always 0 — when Metro serves multiple widget bundles from the same process, it + // shares its module-id registry across bundles so the second/third widget's entry + // gets a higher id (e.g. `__r(74);`). We extract whatever id Metro produced and + // re-invoke it; Metro's `__r` caches module exports, so the second invocation + // returns the same exports the bundle already evaluated. + let entryModuleId = extractEntryModuleId(from: source) ?? 0 + let wrapped = """ + \(source) + ;(function () { + if (!globalThis.__voltraWidgets) { globalThis.__voltraWidgets = {}; } + globalThis.__voltraWidgets[\(escapedId)] = __r(\(entryModuleId)); + })(); + """ + + ctx.exception = nil + ctx.evaluateScript(wrapped) + if let message = exceptionMessage(ctx) { + VoltraLogger.widget.error("[\(TAG)] Bundle eval failed for widgetId=\(widgetId): \(message)") + return false + } + + // Verify the bootstrap captured the exports correctly + guard + let registry = ctx.objectForKeyedSubscript("__voltraWidgets"), + !registry.isUndefined, + let widget = registry.objectForKeyedSubscript(widgetId), + !widget.isUndefined, + let renderFn = widget.objectForKeyedSubscript("render"), + renderFn.isObject, + renderFn.objectForKeyedSubscript("call") != nil + else { + VoltraLogger.widget.error("[\(TAG)] Bundle evaluated but did not expose render() for widgetId=\(widgetId)") + return false + } + _ = renderFn + + VoltraLogger.widget.info("[\(TAG)] Bundle evaluated for widgetId=\(widgetId) (\(source.count) chars)") + return true + } + + /// Ensure the widget's bundle is evaluated in *this process's* JSContext, returning true if its + /// `render()` is available afterward. + /// + /// Cheap no-op when the widget is already captured in this process (the warm case: the provider + /// just evaluated it). Evaluates from `source` when the context is fresh — which is what happens + /// when WidgetKit re-renders an archived entry in a new extension process, where the provider's + /// evaluation never ran. The View calls this before `render()` so rendering never depends on + /// which process evaluated the bundle. + public static func ensureEvaluated(widgetId: String, source: String) -> Bool { + lock.lock() + let alreadyEvaluated = + _context? + .objectForKeyedSubscript("__voltraWidgets")? + .objectForKeyedSubscript(widgetId)? + .objectForKeyedSubscript("render")? + .isObject ?? false + lock.unlock() + + if alreadyEvaluated { + return true + } + return evaluateBundle(source: source, widgetId: widgetId) + } + + /// Invoke the previously-evaluated widget's `render(propsJSON, envJSON)` function and + /// return its resolved JSON string output. + /// + /// Returns `nil` if the widget hasn't been evaluated, the function throws, or the + /// result is not a string. + public static func render( + widgetId: String, + propsJSON: String, + envJSON: String + ) -> String? { + lock.lock() + defer { lock.unlock() } + + guard let ctx = _context else { + VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no JSContext (call evaluateBundle first)") + return nil + } + + guard + let registry = ctx.objectForKeyedSubscript("__voltraWidgets"), + !registry.isUndefined, + let widget = registry.objectForKeyedSubscript(widgetId), + !widget.isUndefined, + let renderFn = widget.objectForKeyedSubscript("render"), + renderFn.isObject + else { + VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no captured render() — bundle not evaluated?") + return nil + } + + ctx.exception = nil + guard let result = renderFn.call(withArguments: [propsJSON, envJSON]) else { + VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): call returned nil") + return nil + } + if let message = exceptionMessage(ctx) { + VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) threw: \(message)") + return nil + } + + guard result.isString else { + VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) did not return a string") + return nil + } + return result.toString() + } + + // MARK: - Lifecycle + + private static func context() -> JSContext? { + if let existing = _context { return existing } + guard let ctx = JSContext() else { + VoltraLogger.widget.error("[\(TAG)] Failed to create JSContext") + return nil + } + ctx.exceptionHandler = { _, exception in + VoltraLogger.widget.error("[\(TAG)] JS exception: \(exception?.toString() ?? "unknown")") + } + _context = ctx + return ctx + } + + // MARK: - Helpers + + private static func exceptionMessage(_ ctx: JSContext) -> String? { + guard let exc = ctx.exception, !exc.isUndefined, !exc.isNull else { return nil } + ctx.exception = nil + return exc.toString() + } + + private static func jsStringLiteral(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + return "\"\(escaped)\"" + } + + /// Find the entry module id Metro emitted in the bundle's trailing `__r();`. + /// Scans the LAST occurrence of `__r();` in the source — every `__d(...)` + /// module declaration also contains internal `__r(...)` calls, so taking the last + /// match (the entrypoint invocation Metro appends at bundle's end) is what we want. + private static func extractEntryModuleId(from source: String) -> Int? { + guard let regex = try? NSRegularExpression(pattern: #"__r\((\d+)\);"#) else { return nil } + let nsRange = NSRange(source.startIndex ..< source.endIndex, in: source) + let matches = regex.matches(in: source, range: nsRange) + guard let last = matches.last, + let range = Range(last.range(at: 1), in: source), + let id = Int(source[range]) + else { return nil } + return id + } +} diff --git a/packages/ios-client/ios/shared/VoltraWidgetDefaults.swift b/packages/ios-client/ios/shared/VoltraWidgetDefaults.swift index 22e1f86e..18e794c6 100644 --- a/packages/ios-client/ios/shared/VoltraWidgetDefaults.swift +++ b/packages/ios-client/ios/shared/VoltraWidgetDefaults.swift @@ -37,6 +37,12 @@ public enum VoltraWidgetDefaults { try? resolvedDefaults().string(forKey: VoltraStorageKeys.widgetTimeline(widgetId)) } + /// Metro dev-server base URL relayed from the app (DEBUG). Read by the client-widget runtime; + /// nil falls back to localhost. + public static func devServerURL() -> String? { + try? resolvedDefaults().string(forKey: VoltraStorageKeys.devServerURL) + } + // MARK: - Write public static func setWidgetJson(_ json: String, for widgetId: String, deepLinkUrl: String?) throws { @@ -70,6 +76,14 @@ public enum VoltraWidgetDefaults { defaults.synchronize() } + /// Relay the Metro dev-server base URL to the widget extension (DEBUG). Best-effort: a missing + /// app group just leaves the extension on its localhost fallback. + public static func setDevServerURL(_ url: String) { + guard let defaults = try? resolvedDefaults() else { return } + defaults.set(url, forKey: VoltraStorageKeys.devServerURL) + defaults.synchronize() + } + // MARK: - Remove /// Removes all persisted data (json, deepLinkUrl, timeline) for a single widget. diff --git a/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift new file mode 100644 index 00000000..2d9bc250 --- /dev/null +++ b/packages/ios-client/ios/target/VoltraClientWidgetRuntime.swift @@ -0,0 +1,379 @@ +import Foundation +import SwiftUI +import WidgetKit + +// Shared runtime for client-rendered widgets. +// +// This file is compiled into the VoltraWidget framework alongside the existing +// VoltraHomeWidget.swift, so widget extension code generated by the plugin can +// reference VoltraClientWidget* types without any per-widget glue duplicated. +// +// Pipeline: +// +// 1. Provider runs on a WidgetKit timeline tick. +// - Fetches the JS bundle (dev = Metro HTTP, prod = baked asset stub). +// - Evaluates it once in the shared JSContext via VoltraJSRenderer. +// - Emits a VoltraClientWidgetEntry with `bundleReady = true` (or errorMessage on failure). +// +// 2. ContentView body runs per (family, scheme, renderingMode) combo. +// - Reads SwiftUI @Environment values (which are ONLY accessible inside a View body). +// - Builds envJSON matching WidgetEnvironment in packages/core/src/widget-environment.ts. +// - Calls VoltraJSRenderer.render(widgetId, propsJSON, envJSON) → resolved JSON string. +// - Parses the resolved JSON into a VoltraNode and hands a VoltraHomeWidgetEntry to +// the existing VoltraHomeWidgetView so client-rendered widgets reach the same UI +// renderer as server-rendered ones. +// +// On bundle-load failure the View falls back to the prerendered initial state. + +// MARK: - Entry + +public struct VoltraClientWidgetEntry: TimelineEntry { + public let date: Date + public let widgetId: String + public let bundleReady: Bool + public let errorMessage: String? + /// User-configured AppIntent parameters → `env.configuration`. Empty for widgets without an + /// AppIntent configuration; populated by the generated AppIntentTimelineProvider from the + /// configured intent. + public let configuration: [String: String] + /// The evaluated widget's JS bundle. Carried on the entry (not just left in the provider's + /// JSContext) because WidgetKit archives entries and re-renders the View in a *fresh* extension + /// process, where the provider's process-static JSContext is empty. The View re-evaluates from + /// this source so `render()` always has the widget's function available in its own process. + public let bundleSource: String? + + public init( + date: Date, + widgetId: String, + bundleReady: Bool, + errorMessage: String? = nil, + configuration: [String: String] = [:], + bundleSource: String? = nil + ) { + self.date = date + self.widgetId = widgetId + self.bundleReady = bundleReady + self.errorMessage = errorMessage + self.configuration = configuration + self.bundleSource = bundleSource + } +} + +// MARK: - Provider + +// +// Refresh model: +// +// - DEBUG builds: Provider fetches `.bundle` from Metro on every +// `getTimeline`/`getSnapshot`, evaluates it in the shared JSContext, and emits +// `bundleReady = true`. The ContentView then runs the freshly evaluated +// `render(props, env)` and feeds the result to `VoltraHomeWidgetView`. +// +// - Release builds: Provider attempts to load a baked-in bundle (release-path loader is +// a future addition); on failure the ContentView renders the prerendered initial state. +// +// - Timeline policy is ALWAYS `.never`. The widget refreshes only when something +// explicitly calls `WidgetCenter.shared.reloadAllTimelines()` or when WidgetKit +// naturally re-invokes the Provider on its own lifecycle events (e.g., host app +// foregrounding). iOS rate-limits timeline-policy-driven refresh aggressively +// (~5-minute floor even in the simulator), so explicit reloads or natural +// lifecycle re-invocations are the only mechanisms that deliver fresh content. + +public struct VoltraClientWidgetProvider: TimelineProvider { + public let widgetId: String + /// Prerendered initial state JSON from `VoltraWidgetInitialStates.getInitialState(for:)`. + /// Used as the placeholder, the Loading fallback, and the steady-state view if a bundle + /// load fails. + public let initialState: Data? + + public init(widgetId: String, initialState: Data? = nil) { + self.widgetId = widgetId + self.initialState = initialState + } + + public func placeholder(in _: Context) -> VoltraClientWidgetEntry { + VoltraClientWidgetEntry(date: Date(), widgetId: widgetId, bundleReady: false) + } + + public func getSnapshot(in _: Context, completion: @escaping (VoltraClientWidgetEntry) -> Void) { + Task { completion(await loadBundleEntry()) } + } + + public func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + Task { + let entry = await loadBundleEntry() + completion(Timeline(entries: [entry], policy: .never)) + } + } + + private func loadBundleEntry() async -> VoltraClientWidgetEntry { + await VoltraClientWidgetProvider.loadEntry(widgetId: widgetId, configuration: [:]) + } + + /// Fetch + evaluate the widget bundle and build an entry. Shared by this `TimelineProvider` and + /// the plugin-generated `AppIntentTimelineProvider`s (which pass the user-configured params as + /// `configuration`). + public static func loadEntry(widgetId: String, configuration: [String: String]) async -> VoltraClientWidgetEntry { + let date = Date() + + let source: String + do { + source = try await VoltraClientWidgetBundleSource.load(widgetId: widgetId) + } catch { + return VoltraClientWidgetEntry( + date: date, + widgetId: widgetId, + bundleReady: false, + errorMessage: error.localizedDescription, + configuration: configuration + ) + } + + let okEval = VoltraJSRenderer.evaluateBundle(source: source, widgetId: widgetId) + if !okEval { + return VoltraClientWidgetEntry( + date: date, + widgetId: widgetId, + bundleReady: false, + errorMessage: "Bundle eval failed (see logs)", + configuration: configuration + ) + } + return VoltraClientWidgetEntry( + date: date, + widgetId: widgetId, + bundleReady: true, + configuration: configuration, + bundleSource: source + ) + } +} + +// MARK: - Bundle source (dev vs prod) + +// +// Dual-path bundle loader. Dev reads from Metro localhost; prod reads from a baked asset +// in the .app bundle. The build-time bundle writer that produces the baked asset is a +// future addition; until then prod throws a clear error. + +public enum VoltraClientWidgetBundleSource { + public enum LoadError: LocalizedError { + case metroHTTP(Int) + case nonUTF8 + case bakedBundleNotFound(widgetId: String) + + public var errorDescription: String? { + switch self { + case let .metroHTTP(code): + return "Metro HTTP \(code) — is the dev server running?" + case .nonUTF8: + return "Bundle response was not UTF-8 text" + case let .bakedBundleNotFound(id): + return "Production bundle missing for widgetId=\(id) (release path not yet implemented)" + } + } + } + + public static func load(widgetId: String) async throws -> String { + #if DEBUG + return try await loadFromMetro(widgetId: widgetId) + #else + return try loadFromBakedAsset(widgetId: widgetId) + #endif + } + + private static func loadFromMetro(widgetId: String) async throws -> String { + // Dev mode = always-refetch. URLSession default cache policy is fine — + // Metro's bundle responses are not cacheable, so each request hits the server. + // + // The base URL is relayed from the app via the app group (the app resolves it with + // RCTBundleURLProvider; this extension is React-free). Falls back to localhost:8081 when the + // app hasn't written it yet (e.g. first render before the host app has run). + let base = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081" + let urlString = "\(base)/voltra/widgets/\(widgetId).bundle?platform=ios&dev=true" + guard let url = URL(string: urlString) else { + throw LoadError.metroHTTP(-1) + } + let (data, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) { + throw LoadError.metroHTTP(httpResponse.statusCode) + } + guard let text = String(data: data, encoding: .utf8) else { + throw LoadError.nonUTF8 + } + return text + } + + /// Release-path stub — currently always throws. The plan is to emit the baked bundle + /// at `expo prebuild` (config plugin step) into the extension target's resources, then + /// read it here with Bundle.main.url(forResource:withExtension:). + private static func loadFromBakedAsset(widgetId: String) throws -> String { + if let url = Bundle.main.url(forResource: "voltra-widget-\(widgetId)", withExtension: "bundle"), + let text = try? String(contentsOf: url, encoding: .utf8) + { + return text + } + throw LoadError.bakedBundleNotFound(widgetId: widgetId) + } +} + +// MARK: - Env capture + JSON marshaling + +// +// SwiftUI @Environment values are read inside the View body (see ContentView below) and +// passed here as plain values. This helper emits a JSON string matching the +// WidgetEnvironment type from packages/core/src/widget-environment.ts. We hand-roll the +// JSON instead of going through JSONSerialization so the shape is locked to that type and +// any drift produces a Swift compile error rather than a runtime parse failure. + +public enum VoltraClientWidgetEnvBuilder { + public static func build( + date: Date, + widgetFamily: WidgetFamily, + colorScheme: ColorScheme?, + widgetRenderingMode: WidgetRenderingMode, + showsWidgetContainerBackground: Bool, + locale: Locale, + configuration: [String: String] + ) -> String { + let timestampMs = Int(date.timeIntervalSince1970 * 1000) + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + + #if DEBUG + let isDev = true + let metroUrl: String? = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081" + #else + let isDev = false + let metroUrl: String? = nil + #endif + + let metroUrlLiteral = metroUrl.map(jsonString) ?? "null" + let buildJSON = """ + { + "isDev": \(isDev), + "metroUrl": \(metroUrlLiteral), + "appVersion": \(jsonString(appVersion)), + "voltraVersion": \(jsonString("1.4.1")) + } + """ + + let configurationJSON: String + if configuration.isEmpty { + configurationJSON = "{}" + } else { + let entries = configuration + .map { "\(jsonString($0.key)): \(jsonString($0.value))" } + .joined(separator: ", ") + configurationJSON = "{ \(entries) }" + } + + return """ + { + "date": \(timestampMs), + "widgetFamily": \(jsonString(familyString(widgetFamily))), + "colorScheme": \(jsonString(schemeString(colorScheme))), + "locale": \(jsonString(locale.identifier)), + "widgetRenderingMode": \(jsonString(renderingModeString(widgetRenderingMode))), + "showsWidgetContainerBackground": \(showsWidgetContainerBackground), + "configuration": \(configurationJSON), + "build": \(buildJSON) + } + """ + } + + /// All three SwiftUI enums have String(describing:) representations that match their + /// case names exactly (e.g. "systemMedium", "fullColor", "dark"). Using String(describing:) + /// keeps these helpers forward-compatible when Apple adds new cases in a future SDK. + private static func familyString(_ family: WidgetFamily) -> String { + String(describing: family) + } + + private static func renderingModeString(_ mode: WidgetRenderingMode) -> String { + String(describing: mode) + } + + private static func schemeString(_ scheme: ColorScheme?) -> String { + guard let scheme else { return "light" } + return String(describing: scheme) + } + + private static func jsonString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + return "\"\(escaped)\"" + } +} + +// MARK: - Content view + +// +// Per Q5 grilling: the resolved JSON is parsed into a VoltraNode and rendered via the +// existing VoltraHomeWidgetView so client-rendered widgets are visually indistinguishable +// from server-rendered ones at the UI layer. + +public struct VoltraClientWidgetContentView: View { + public let entry: VoltraClientWidgetEntry + public let initialState: Data? + + @Environment(\.widgetFamily) private var widgetFamily + @Environment(\.colorScheme) private var colorScheme + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + @Environment(\.locale) private var locale + + public init(entry: VoltraClientWidgetEntry, initialState: Data?) { + self.entry = entry + self.initialState = initialState + } + + public var body: some View { + let homeEntry = makeHomeEntry() + return VoltraHomeWidgetView(entry: homeEntry) + } + + private func makeHomeEntry() -> VoltraHomeWidgetEntry { + if entry.bundleReady { + // WidgetKit may render this archived entry in a fresh extension process where the provider's + // bundle evaluation didn't happen. Re-evaluate from the entry's carried source (no-op if this + // process already has it) so render() finds the widget's function. + if let source = entry.bundleSource { + _ = VoltraJSRenderer.ensureEvaluated(widgetId: entry.widgetId, source: source) + } + let envJSON = VoltraClientWidgetEnvBuilder.build( + date: entry.date, + widgetFamily: widgetFamily, + colorScheme: colorScheme, + widgetRenderingMode: widgetRenderingMode, + showsWidgetContainerBackground: showsWidgetContainerBackground, + locale: locale, + configuration: entry.configuration + ) + if let resolved = VoltraJSRenderer.render( + widgetId: entry.widgetId, + propsJSON: "{}", + envJSON: envJSON + ), let node = parseResolvedNode(jsonString: resolved) { + return VoltraHomeWidgetEntry(date: entry.date, rootNode: node, widgetId: entry.widgetId) + } + // render() or VoltraNode.parse failed — fall through to the prerendered initial state + // so the widget still shows real UI instead of a blank tile. + } + let fallbackNode = initialState.flatMap(parseResolvedNode(jsonData:)) + return VoltraHomeWidgetEntry(date: entry.date, rootNode: fallbackNode, widgetId: entry.widgetId) + } + + private func parseResolvedNode(jsonString: String) -> VoltraNode? { + guard let json = try? JSONValue.parse(from: jsonString) else { return nil } + let node = VoltraNode.parse(from: json) + if case .empty = node { return nil } + return node + } + + private func parseResolvedNode(jsonData: Data) -> VoltraNode? { + guard let text = String(data: jsonData, encoding: .utf8) else { return nil } + return parseResolvedNode(jsonString: text) + } +} diff --git a/packages/ios-client/package.json b/packages/ios-client/package.json index 0a4816de..66d130b4 100644 --- a/packages/ios-client/package.json +++ b/packages/ios-client/package.json @@ -49,6 +49,7 @@ "@babel/core": "^7.27.4", "@expo/config-plugins": "~10.1.2", "@expo/plist": "^0.3.5", + "@use-voltra/compiler": "workspace:^", "@use-voltra/expo-plugin": "workspace:^", "@use-voltra/ios": "workspace:^", "dedent": "^1.7.1", diff --git a/packages/ios-client/src/index.ts b/packages/ios-client/src/index.ts index 3f9e3d15..f53fb4f3 100644 --- a/packages/ios-client/src/index.ts +++ b/packages/ios-client/src/index.ts @@ -31,6 +31,7 @@ export { reloadLiveActivities, } from './preload.js' export { assertRunningOnApple } from './utils/assertRunningOnApple.js' +export { enableWidgetHotReload } from './utils/enableWidgetHotReload.js' export { useUpdateOnHMR } from './utils/useUpdateOnHMR.js' export * from './utils/helpers.js' export type { VoltraElementJson, VoltraNodeJson } from './types.js' diff --git a/packages/ios-client/src/utils/enableWidgetHotReload.ts b/packages/ios-client/src/utils/enableWidgetHotReload.ts new file mode 100644 index 00000000..8d88ec29 --- /dev/null +++ b/packages/ios-client/src/utils/enableWidgetHotReload.ts @@ -0,0 +1,39 @@ +import { reloadWidgets } from '../widgets/widget-api.js' + +declare global { + var __accept: (...args: unknown[]) => void +} + +/** + * Trigger `reloadWidgets()` on every Metro Fast Refresh patch (DEV only). + * + * Hooks the global `__accept` callback Metro fires when a Fast Refresh patch + * lands in the host app's JS runtime. When fired, calls + * `WidgetCenter.shared.reloadAllTimelines()` so WidgetKit re-invokes each widget + * Provider, which re-fetches the freshest bundle from Metro and renders the + * updated UI. + * + * Only effective while the host app's JS thread is alive — iOS suspends the + * RN runtime within ~5 seconds of backgrounding, so the "edit while staring at + * the home screen, never touch the host app" case is not covered. For that + * workflow the dev still relies on WidgetKit's natural lifecycle refresh on + * app foreground. + * + * Call once at app startup. Returns `dispose()` to restore the prior + * `__accept` (rarely needed in practice). No-op in release builds. + */ +export function enableWidgetHotReload(): () => void { + if (!__DEV__) { + return () => {} + } + + const oldAccept = global['__accept'] + global['__accept'] = (...args) => { + void reloadWidgets() + oldAccept?.(...args) + } + + return () => { + global['__accept'] = oldAccept + } +} diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index 8266b377..6fe6efd7 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -32,3 +32,5 @@ export type { } from './types.js' export { renderWidgetToJson, renderWidgetToString } from './widgets/renderer.js' export type { ScheduledWidgetEntry, WidgetFamily, WidgetInfo, WidgetVariants } from './widgets/types.js' +export { isAndroidEnv, isIosEnv } from '@use-voltra/core' +export type { MaterialColorScheme, WidgetBuildEnvironment, WidgetEnvironment } from '@use-voltra/core' diff --git a/packages/metro/README.md b/packages/metro/README.md new file mode 100644 index 00000000..37187869 --- /dev/null +++ b/packages/metro/README.md @@ -0,0 +1,98 @@ +![voltra-banner](https://use-voltra.dev/voltra-baner.jpg) + +### Metro integration for Voltra client-rendered widgets + +[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome] + +`@use-voltra/metro` adds the Metro-side plumbing for Voltra's client-rendered widgets. It scans `'use voltra'` components, wires the dev server middleware, resolves the hot-reload alias, and bundles widget code for release builds. + +## Features + +- **Metro config transformer**: Wrap your app's Metro config with `withVoltra()` to enable Voltra's widget pipeline. + +- **Client-rendered widget scanning**: Discover exported components marked with `'use voltra'` and turn them into widget entries. + +- **Dev server middleware**: Serve widget bundles from `/voltra` during development and keep the hot-reload path working. + +- **Release bundling**: Build standalone widget bundles with the `bundle-widgets` CLI for production shipping. + +- **Project-aware module resolution**: Resolve app dependencies from the consuming project's install layout, including pnpm setups. + +## Documentation + +This package powers the client-rendered widget workflow in Voltra. Relevant topics: + +- [Getting Started](https://use-voltra.dev/getting-started/installation) +- [iOS Widgets](https://use-voltra.dev/ios/development/developing-widgets) +- [Android Widgets](https://use-voltra.dev/android/development/developing-widgets) +- [iOS API Reference](https://use-voltra.dev/ios/api/configuration) +- [Android API Reference](https://use-voltra.dev/android/api/plugin-configuration) + +## Getting started + +`@use-voltra/metro` is usually consumed through `@use-voltra/ios-client` or `@use-voltra/android-client`, but you can install it directly if you need to customize Metro yourself. + +```sh +npm install @use-voltra/metro +``` + +Wrap your Metro config with `withVoltra`: + +```ts +import { withVoltra } from '@use-voltra/metro' + +export default withVoltra({ + projectRoot: __dirname, + resolver: { + sourceExts: ['ts', 'tsx', 'js', 'jsx'], + }, +}) +``` + +If you are using client-rendered widgets, make sure your widget component includes the `'use voltra'` directive and follows the setup from the Voltra docs. + +## Quick example + +```ts +import { bundleWidgets, scanVoltraDirectives } from '@use-voltra/metro' + +const widgets = scanVoltraDirectives({ + filePath: '/app/widgets/WeatherWidget.tsx', + source: ` + export function WeatherWidget() { + 'use voltra' + return null + } + `, +}) + +console.log(widgets) + +await bundleWidgets({ + projectRoot: process.cwd(), + outDir: './dist/widgets', + platform: 'ios', +}) +``` + +## Platform compatibility + +This package works with Metro-based React Native apps on **iOS** and **Android** when you are using Voltra client-rendered widgets. + +## Authors + +Voltra is an open source collaboration between [Saúl Sharma](https://github.com/saulsharma) and [Szymon Chmal](https://github.com/szymonchmal) at [Callstack][callstack-readme-with-love]. + +If you think it's cool, please star it 🌟. This project will always remain free to use. + +[Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the Callstack team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=voltra&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/@use-voltra/metro?style=for-the-badge +[license]: https://github.com/callstackincubator/voltra/blob/main/LICENSE.txt +[npm-downloads-badge]: https://img.shields.io/npm/dm/@use-voltra/metro?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/@use-voltra/metro +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ../../CONTRIBUTING.md diff --git a/packages/metro/package.json b/packages/metro/package.json new file mode 100644 index 00000000..83dbd5b5 --- /dev/null +++ b/packages/metro/package.json @@ -0,0 +1,83 @@ +{ + "name": "@use-voltra/metro", + "version": "1.4.1", + "description": "Metro integration for Voltra client-rendered widgets", + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "typesVersions": { + "*": { + "scanner": [ + "build/types/scanner.d.ts" + ], + "bundle-widgets": [ + "build/types/bin/bundle-widgets.d.ts" + ], + "*": [ + "build/types/*" + ] + } + }, + "exports": { + ".": { + "types": "./build/types/index.d.ts", + "require": "./build/cjs/index.js", + "import": "./build/esm/index.js", + "default": "./build/esm/index.js" + }, + "./scanner": { + "types": "./build/types/scanner.d.ts", + "require": "./build/cjs/scanner.js", + "import": "./build/esm/scanner.js", + "default": "./build/esm/scanner.js" + }, + "./bundle-widgets": { + "types": "./build/types/bin/bundle-widgets.d.ts", + "require": "./build/cjs/bin/bundle-widgets.js", + "import": "./build/esm/bin/bundle-widgets.js", + "default": "./build/cjs/bin/bundle-widgets.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "bundle-widgets": "./build/cjs/bin/bundle-widgets.js" + }, + "files": [ + "build", + "README.md" + ], + "scripts": { + "build": "node ../../scripts/build-package.mjs packages/metro", + "clean": "rm -rf build", + "lint": "oxlint src", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "test": "node --test" + }, + "dependencies": { + "@use-voltra/compiler": "workspace:^", + "metro-config-transformers": "^1.0.0" + }, + "peerDependencies": { + "expo": "*", + "metro": "*", + "react": "*", + "react-native": "*" + }, + "keywords": [ + "react-native", + "expo", + "metro", + "voltra" + ], + "author": "Saúl Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/voltra.git", + "directory": "packages/metro" + }, + "bugs": { + "url": "https://github.com/callstackincubator/voltra/issues" + }, + "license": "MIT", + "homepage": "https://use-voltra.dev" +} diff --git a/packages/metro/src/bin/bundle-widgets.ts b/packages/metro/src/bin/bundle-widgets.ts new file mode 100644 index 00000000..22f7814f --- /dev/null +++ b/packages/metro/src/bin/bundle-widgets.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { runBundleWidgetsCli } from '../bundleWidgets' + +runBundleWidgetsCli() + .then(() => { + process.exit(0) + }) + .catch((error) => { + console.error( + `[voltra] widget bundling failed: ${error instanceof Error ? error.stack || error.message : String(error)}` + ) + process.exit(1) + }) diff --git a/packages/metro/src/bundleWidgets.ts b/packages/metro/src/bundleWidgets.ts new file mode 100644 index 00000000..7567c380 --- /dev/null +++ b/packages/metro/src/bundleWidgets.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { createWidgetMetroConfig } from './createWidgetMetroConfig' +import { requireProjectModule } from './resolveProjectModule' +import { createWidgetRegistry, type RegisteredVoltraWidget } from './widgetRegistry' + +export type BundleWidgetsOptions = { + projectRoot: string + outDir: string + platform: string +} + +type ParsedArgs = { + outDir: string | null + platform: string + projectRoot: string +} + +export function parseBundleWidgetsArgs(argv: string[]): ParsedArgs { + const args: ParsedArgs = { outDir: null, platform: 'ios', projectRoot: process.cwd() } + for (let i = 2; i < argv.length; i += 1) { + const value = argv[i + 1] + switch (argv[i]) { + case '--out-dir': + args.outDir = value + i += 1 + break + case '--platform': + args.platform = value + i += 1 + break + case '--project-root': + args.projectRoot = path.resolve(value) + i += 1 + break + default: + break + } + } + return args +} + +async function loadAppMetroConfig(projectRoot: string): Promise { + const { loadConfig } = requireProjectModule<{ loadConfig(argv?: any): Promise }>('metro-config', projectRoot) + return loadConfig({ cwd: projectRoot }) +} + +function widgetMatchesPlatform(widget: RegisteredVoltraWidget, projectRoot: string, platform: string): boolean { + const segments = path.relative(projectRoot, widget.sourcePath).split(path.sep) + if (segments.includes('android')) { + return platform === 'android' + } + if (segments.includes('ios')) { + return platform === 'ios' + } + return true +} + +export async function bundleWidgets({ projectRoot, outDir, platform }: BundleWidgetsOptions): Promise { + if (!outDir) { + throw new Error('bundleWidgets: --out-dir is required') + } + + const Metro = requireProjectModule<{ runBuild(config: any, options: any): Promise<{ code: string }> }>( + 'metro', + projectRoot + ) + const appConfig = await loadAppMetroConfig(projectRoot) + const widgetConfig = await createWidgetMetroConfig({ projectRoot, appConfig }) + const registry = createWidgetRegistry({ projectRoot }) + + try { + const widgets = Array.from(registry.listWidgets()) + .map((widget) => registry.getWidget(widget.id)) + .filter((widget): widget is RegisteredVoltraWidget => widget !== null) + .filter((widget) => widgetMatchesPlatform(widget, projectRoot, platform)) + + if (widgets.length === 0) { + console.log(`[voltra] no client-rendered widgets to bundle for platform "${platform}"`) + return + } + + fs.mkdirSync(outDir, { recursive: true }) + + for (const widget of widgets) { + const entry = path.resolve(projectRoot, widget.generatedEntryRelativePath) + const { code } = await Metro.runBuild(widgetConfig, { + entry, + platform, + dev: false, + minify: true, + }) + + const outPath = path.join(outDir, `voltra-widget-${widget.id}.bundle`) + fs.writeFileSync(outPath, code) + console.log(`[voltra] baked ${path.basename(outPath)} (${code.length} bytes)`) + } + } finally { + registry.close() + } +} + +export async function runBundleWidgetsCli(argv = process.argv): Promise { + const args = parseBundleWidgetsArgs(argv) + await bundleWidgets({ + projectRoot: args.projectRoot, + outDir: args.outDir ?? '', + platform: args.platform, + }) +} diff --git a/packages/metro/src/createVoltraMiddleware.ts b/packages/metro/src/createVoltraMiddleware.ts new file mode 100644 index 00000000..e0661114 --- /dev/null +++ b/packages/metro/src/createVoltraMiddleware.ts @@ -0,0 +1,68 @@ +import type { WidgetRegistry } from './widgetRegistry' + +type Middleware = (req: any, res: any, next: () => void) => void + +function sendJson(res: any, status: number, value: unknown): void { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + }) + res.end(JSON.stringify(value, null, 2)) +} + +function createBundleRequest( + widget: { generatedEntryRelativePath: string }, + originalSearchParams: URLSearchParams +): string { + const query = new URLSearchParams(originalSearchParams) + query.set('bundleEntry', widget.generatedEntryRelativePath) + + if (!query.has('platform')) { + query.set('platform', 'voltra') + } + + return `/voltra-widget.bundle?${query.toString()}` +} + +export function createVoltraMiddleware({ + registry, + widgetMetro, +}: { + registry: WidgetRegistry + widgetMetro: { middleware: Middleware } +}): Middleware { + return (req, res, next) => { + const requestUrl = new URL(req.url, 'http://localhost') + const pathname = requestUrl.pathname || '/' + + if (pathname === '/' || pathname === '/widgets') { + sendJson(res, 200, { + ready: registry.isReady(), + widgets: registry.listWidgets(), + }) + return + } + + const widgetBundleMatch = pathname.match(/^\/widgets\/([^/]+)\.bundle$/) + if (widgetBundleMatch) { + const widgetId = decodeURIComponent(widgetBundleMatch[1]) + const widget = registry.getWidget(widgetId) + + if (!widget) { + sendJson(res, registry.isReady() ? 404 : 425, { + error: registry.isReady() + ? `Unknown Voltra widget "${widgetId}".` + : 'Voltra widget registry is not ready yet. Build the app bundle first.', + }) + return + } + + req.url = createBundleRequest(widget, requestUrl.searchParams) + widgetMetro.middleware(req, res, next) + return + } + + sendJson(res, 404, { + error: `Unknown Voltra endpoint "${pathname}".`, + }) + } +} diff --git a/packages/metro/src/createWidgetMetroConfig.ts b/packages/metro/src/createWidgetMetroConfig.ts new file mode 100644 index 00000000..bc6654ea --- /dev/null +++ b/packages/metro/src/createWidgetMetroConfig.ts @@ -0,0 +1,82 @@ +import path from 'node:path' + +import { requireProjectModule, resolveProjectModulePath } from './resolveProjectModule' + +const blockedModules = new Set(['react-native']) + +function unique(items: Array): T[] { + return Array.from(new Set(items.filter((item): item is T => item !== null && item !== undefined))) +} + +function resolvePnpmTransitive(name: string, projectRoot: string): string | null { + try { + return path.dirname(resolveProjectModulePath(`${name}/package.json`, projectRoot)) + } catch { + return null + } +} + +export async function createWidgetMetroConfig({ + projectRoot, + appConfig, +}: { + projectRoot: string + appConfig: any +}): Promise { + const appNodeModules = path.join(projectRoot, 'node_modules') + const { getDefaultConfig } = requireProjectModule<{ getDefaultConfig(rootPath: string): Promise }>( + 'metro-config', + projectRoot + ) + const config = await getDefaultConfig(projectRoot) + const sourceExts = unique([...(config.resolver?.sourceExts ?? []), ...(appConfig.resolver?.sourceExts ?? [])]) + const pnpmTransitives = { + '@babel/runtime': resolvePnpmTransitive('@babel/runtime', projectRoot), + 'metro-runtime': resolvePnpmTransitive('metro-runtime', projectRoot), + } + const pnpmTransitiveModules = Object.fromEntries( + Object.entries(pnpmTransitives).filter((entry): entry is [string, string] => entry[1] !== null) + ) + + return { + ...config, + projectRoot, + watchFolders: unique([...(config.watchFolders ?? []), ...(appConfig.watchFolders ?? [])]), + resolver: { + ...config.resolver, + sourceExts, + extraNodeModules: { + ...config.resolver?.extraNodeModules, + ...appConfig.resolver?.extraNodeModules, + ...pnpmTransitiveModules, + react: path.join(appNodeModules, 'react'), + }, + nodeModulesPaths: unique([ + appNodeModules, + ...(config.resolver?.nodeModulesPaths ?? []), + ...(appConfig.resolver?.nodeModulesPaths ?? []), + ]), + resolveRequest(context: any, moduleName: string, platform: string | null) { + if (blockedModules.has(moduleName) || moduleName.startsWith('react-native/')) { + throw new Error(`Voltra widget bundles cannot import "${moduleName}"`) + } + + return context.resolveRequest(context, moduleName, platform) + }, + }, + serializer: { + ...config.serializer, + getModulesRunBeforeMainModule: () => [], + getPolyfills: () => [], + polyfillModuleNames: [], + }, + transformer: { + ...config.transformer, + babelTransformerPath: appConfig.transformer?.babelTransformerPath, + }, + server: { + ...config.server, + enhanceMiddleware: (middleware: unknown) => middleware, + }, + } +} diff --git a/packages/metro/src/index.ts b/packages/metro/src/index.ts new file mode 100644 index 00000000..0ff7cc34 --- /dev/null +++ b/packages/metro/src/index.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { createMetroConfigTransformer } from 'metro-config-transformers' + +import { bundleWidgets } from './bundleWidgets' +import { createVoltraMiddleware } from './createVoltraMiddleware' +import { createWidgetMetroConfig } from './createWidgetMetroConfig' +import { requireProjectModule } from './resolveProjectModule' +import { createWidgetRegistry, ensureEmptyDevBarrel } from './widgetRegistry' + +const HOT_RELOAD_ALIAS = '@use-voltra/widget-hot-reload' +const DEV_BARREL_PLATFORMS = new Set(['ios', 'android']) + +type ResolveRequest = (context: any, moduleName: string, platform: string | null) => unknown + +function resolveHotReloadAlias(projectRoot: string, context: any, platform: string | null): unknown { + if (!context.dev) { + return { type: 'empty' } + } + + if (!platform || !DEV_BARREL_PLATFORMS.has(platform)) { + return { type: 'sourceFile', filePath: ensureEmptyDevBarrel(projectRoot) } + } + + const platformBarrel = path.join(projectRoot, '.voltra', 'metro', `widgets-dev-barrel.${platform}.js`) + if (!fs.existsSync(platformBarrel)) { + return { type: 'sourceFile', filePath: ensureEmptyDevBarrel(projectRoot) } + } + + return { type: 'sourceFile', filePath: platformBarrel } +} + +function createResolveRequest( + projectRoot: string, + previousResolveRequest: ResolveRequest | null | undefined +): ResolveRequest { + return (context, moduleName, platform) => { + if (moduleName === HOT_RELOAD_ALIAS) { + return resolveHotReloadAlias(projectRoot, context, platform) + } + + if (previousResolveRequest) { + return previousResolveRequest(context, moduleName, platform) + } + + return context.resolveRequest(context, moduleName, platform) + } +} + +export const withVoltra = createMetroConfigTransformer(async (metroConfig: any) => { + const projectRoot = metroConfig.projectRoot ?? process.cwd() + const registry = createWidgetRegistry({ projectRoot }) + const widgetConfig = await createWidgetMetroConfig({ + projectRoot, + appConfig: metroConfig, + }) + const Metro = requireProjectModule<{ createConnectMiddleware(config: any, options?: any): Promise }>( + 'metro', + projectRoot + ) + const connect = requireProjectModule<() => { use(pathOrMiddleware: string | unknown, middleware?: unknown): any }>( + 'connect', + projectRoot + ) + const widgetMetro = await Metro.createConnectMiddleware(widgetConfig, { + port: metroConfig.server?.port, + }) + const voltraMiddleware = createVoltraMiddleware({ + registry, + widgetMetro, + }) + + const previousEnhanceMiddleware = metroConfig.server?.enhanceMiddleware || ((middleware: unknown) => middleware) + const previousResolveRequest = metroConfig.resolver?.resolveRequest + + return { + ...metroConfig, + projectRoot, + resolver: { + ...metroConfig.resolver, + resolveRequest: createResolveRequest(projectRoot, previousResolveRequest), + }, + server: { + ...metroConfig.server, + enhanceMiddleware(metroMiddleware: unknown, metroServer: unknown) { + const enhancedAppMetroMiddleware = previousEnhanceMiddleware(metroMiddleware, metroServer) + + return connect().use('/voltra', voltraMiddleware).use(enhancedAppMetroMiddleware) + }, + }, + } +}) + +export { bundleWidgets, createVoltraMiddleware, createWidgetMetroConfig, createWidgetRegistry } +export { requireProjectModule, resolveProjectModulePath } from './resolveProjectModule' +export { scanVoltraDirectives, type VoltraDirectiveWidget } from './scanner' +export { DuplicateVoltraWidgetError, type RegisteredVoltraWidget, type WidgetRegistry } from './widgetRegistry' diff --git a/packages/metro/src/resolveProjectModule.ts b/packages/metro/src/resolveProjectModule.ts new file mode 100644 index 00000000..9a2295d0 --- /dev/null +++ b/packages/metro/src/resolveProjectModule.ts @@ -0,0 +1,29 @@ +import { createRequire } from 'node:module' +import path from 'node:path' + +function createProjectRequire(projectRoot: string): NodeRequire { + return createRequire(path.join(projectRoot, 'package.json')) +} + +function createExpoMetroRequire(projectRoot: string): NodeRequire { + const requireFromProject = createProjectRequire(projectRoot) + return createRequire(requireFromProject.resolve('expo/metro-config')) +} + +export function requireProjectModule(moduleName: string, projectRoot = process.cwd()): T { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(moduleName) as T + } catch { + return createExpoMetroRequire(projectRoot)(moduleName) as T + } +} + +export function resolveProjectModulePath(moduleName: string, projectRoot = process.cwd()): string { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require.resolve(moduleName) + } catch { + return createExpoMetroRequire(projectRoot).resolve(moduleName) + } +} diff --git a/packages/metro/src/scanner.ts b/packages/metro/src/scanner.ts new file mode 100644 index 00000000..4dab287c --- /dev/null +++ b/packages/metro/src/scanner.ts @@ -0,0 +1,2 @@ +export { scanVoltraDirectives } from '@use-voltra/compiler' +export type { VoltraDirectiveWidget } from '@use-voltra/compiler' diff --git a/packages/metro/src/widgetRegistry.ts b/packages/metro/src/widgetRegistry.ts new file mode 100644 index 00000000..ffdd0e0a --- /dev/null +++ b/packages/metro/src/widgetRegistry.ts @@ -0,0 +1,391 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { scanVoltraDirectives, type VoltraDirectiveWidget } from './scanner' + +const IGNORED_ANYWHERE = new Set(['node_modules']) +const IGNORED_ROOT = new Set(['ios', 'android', 'Pods', 'build', 'dist', 'coverage']) +const SOURCE_EXT = /\.[cm]?[jt]sx?$/ +const USE_VOLTRA_LITERAL = 'use voltra' +const DEV_BARREL_PLATFORMS = ['ios', 'android'] + +export type RegisteredVoltraWidget = VoltraDirectiveWidget & { + generatedEntryPath: string + generatedEntryRelativePath: string +} + +type FileWatcher = { + on(event: string, callback: (filePath: string) => void): void + close(): void +} + +export type WidgetRegistry = { + projectRoot: string + getWidget(widgetId: string): RegisteredVoltraWidget | null + isReady(): boolean + listWidgets(): Array<{ + id: string + componentName: string + exportName: string + sourcePath: string + generatedEntryRelativePath: string + }> + close(): void +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join('/') +} + +function ensureDirectory(directory: string): void { + fs.mkdirSync(directory, { recursive: true }) +} + +function writeFileIfChanged(filePath: string, content: string): void { + try { + if (fs.readFileSync(filePath, 'utf8') === content) { + return + } + } catch { + // File does not exist yet or is unreadable. + } + fs.writeFileSync(filePath, content) +} + +function hash(value: string): string { + return crypto.createHash('sha1').update(value).digest('hex').slice(0, 10) +} + +function safeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9_.-]+/g, '-') +} + +export class DuplicateVoltraWidgetError extends Error { + constructor({ + widgetId, + firstPath, + secondPath, + projectRoot, + }: { + widgetId: string + firstPath: string + secondPath: string + projectRoot: string + }) { + const firstRelativePath = toPosixPath(path.relative(projectRoot, firstPath)) + const secondRelativePath = toPosixPath(path.relative(projectRoot, secondPath)) + + super( + `Duplicate Voltra widget component "${widgetId}" found in both "${firstRelativePath}" and "${secondRelativePath}". ` + + 'Widget IDs are inherited from component names and must be unique.' + ) + + this.name = 'DuplicateVoltraWidgetError' + } +} + +export function ensureEmptyDevBarrel(projectRoot: string): string { + const generatedRoot = path.join(projectRoot, '.voltra', 'metro') + const emptyBarrelPath = path.join(generatedRoot, 'widgets-dev-barrel.empty.js') + ensureDirectory(generatedRoot) + writeFileIfChanged(emptyBarrelPath, '// AUTO-GENERATED - empty Voltra widget hot-reload barrel.\n') + return emptyBarrelPath +} + +export function createWidgetRegistry({ projectRoot = process.cwd() }: { projectRoot?: string } = {}): WidgetRegistry { + const generatedRoot = path.join(projectRoot, '.voltra', 'metro') + const generatedEntryRoot = path.join(generatedRoot, 'entries') + const widgetsById = new Map() + const widgetIdsBySourcePath = new Map() + let ready = false + let watcher: FileWatcher | null = null + + function createGeneratedEntry( + widget: VoltraDirectiveWidget + ): Pick { + ensureDirectory(generatedEntryRoot) + + const entryFileName = `${safeFileName(widget.id)}-${hash(`${widget.sourcePath}:${widget.exportName}`)}.js` + const generatedEntryPath = path.join(generatedEntryRoot, entryFileName) + const importPath = toPosixPath(path.relative(generatedEntryRoot, widget.sourcePath)).replace(/\.[cm]?[jt]sx?$/, '') + const normalizedImportPath = importPath.startsWith('.') ? importPath : `./${importPath}` + const exportExpression = + widget.exportName === 'default' ? 'WidgetModule.default' : `WidgetModule[${JSON.stringify(widget.exportName)}]` + + fs.writeFileSync( + generatedEntryPath, + [ + "import { createElement } from 'react'", + "import { renderVoltraVariantToJson } from '@use-voltra/ios'", + `import * as WidgetModule from ${JSON.stringify(normalizedImportPath)}`, + '', + `const Widget = ${exportExpression}`, + '', + 'if (!Widget) {', + ` throw new Error(${JSON.stringify(`Unable to find Voltra widget export "${widget.exportName}".`)})`, + '}', + '', + '// Voltra client-rendered widget entry - invoked by the native JS runtime on every render.', + 'export function render(propsJSON, envJSON) {', + " const props = typeof propsJSON === 'string' ? (propsJSON ? JSON.parse(propsJSON) : {}) : (propsJSON || {})", + " const env = typeof envJSON === 'string' ? (envJSON ? JSON.parse(envJSON) : {}) : (envJSON || {})", + ' const WidgetWithEnv = (forwardedProps) => Widget(forwardedProps, env)', + ' const resolved = renderVoltraVariantToJson(createElement(WidgetWithEnv, props))', + ' return JSON.stringify(resolved)', + '}', + '', + 'export default render', + 'export { Widget }', + '', + ].join('\n') + ) + + return { + generatedEntryPath, + generatedEntryRelativePath: toPosixPath(path.relative(projectRoot, generatedEntryPath)), + } + } + + function widgetPlatform(widget: VoltraDirectiveWidget): string | null { + const segments = toPosixPath(path.relative(projectRoot, widget.sourcePath)).split('/') + if (segments.includes('android')) { + return 'android' + } + if (segments.includes('ios')) { + return 'ios' + } + return null + } + + function writeDevBarrels(): void { + ensureDirectory(generatedRoot) + ensureEmptyDevBarrel(projectRoot) + + for (const platform of DEV_BARREL_PLATFORMS) { + const imports = Array.from(widgetsById.values()) + .filter((widget) => { + const platformForWidget = widgetPlatform(widget) + return platformForWidget === null || platformForWidget === platform + }) + .map((widget) => { + const importPath = toPosixPath(path.relative(generatedRoot, widget.sourcePath)).replace(SOURCE_EXT, '') + const normalizedImportPath = importPath.startsWith('.') ? importPath : `./${importPath}` + return `import ${JSON.stringify(normalizedImportPath)}` + }) + + const content = [ + '// AUTO-GENERATED - do not edit. Side-effect imports that place every Voltra widget in the', + '// host app dependency graph so Metro Fast Refresh drives dev hot reload of widgets.', + ...imports, + '', + ].join('\n') + + writeFileIfChanged(path.join(generatedRoot, `widgets-dev-barrel.${platform}.js`), content) + } + } + + function removeSourcePath(sourcePath: string): void { + const widgetIds = widgetIdsBySourcePath.get(sourcePath) || [] + + for (const widgetId of widgetIds) { + widgetsById.delete(widgetId) + } + + widgetIdsBySourcePath.delete(sourcePath) + } + + function registerWidgets(sourcePath: string, widgets: VoltraDirectiveWidget[]): RegisteredVoltraWidget[] { + removeSourcePath(sourcePath) + + if (widgets.length === 0) { + return [] + } + + const newWidgetsById = new Map() + for (const widget of widgets) { + const duplicateInSource = newWidgetsById.get(widget.id) + + if (duplicateInSource) { + throw new DuplicateVoltraWidgetError({ + widgetId: widget.id, + firstPath: duplicateInSource.sourcePath, + secondPath: widget.sourcePath, + projectRoot, + }) + } + + newWidgetsById.set(widget.id, widget) + + const existingWidget = widgetsById.get(widget.id) + + if (existingWidget) { + throw new DuplicateVoltraWidgetError({ + widgetId: widget.id, + firstPath: existingWidget.sourcePath, + secondPath: widget.sourcePath, + projectRoot, + }) + } + } + + const registered = widgets.map((widget) => { + const entry = createGeneratedEntry(widget) + const registeredWidget = { + ...widget, + ...entry, + } + + widgetsById.set(widget.id, registeredWidget) + return registeredWidget + }) + + widgetIdsBySourcePath.set( + sourcePath, + registered.map((widget) => widget.id) + ) + + return registered + } + + function scanFile(filePath: string): RegisteredVoltraWidget[] { + let source: string + try { + source = fs.readFileSync(filePath, 'utf8') + } catch { + removeSourcePath(filePath) + return [] + } + + if (!source.includes(USE_VOLTRA_LITERAL)) { + removeSourcePath(filePath) + return [] + } + + try { + const widgets = scanVoltraDirectives({ filePath, source }) + return registerWidgets(filePath, widgets) + } catch (error) { + if (error instanceof DuplicateVoltraWidgetError) { + throw error + } + console.warn( + `[voltra:metro] Failed to scan ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ) + removeSourcePath(filePath) + return [] + } + } + + function scanProject(): void { + const stack = [projectRoot] + while (stack.length > 0) { + const dir = stack.pop() + if (!dir) { + continue + } + + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory()) { + const skip = + IGNORED_ANYWHERE.has(entry.name) || + entry.name.startsWith('.') || + (dir === projectRoot && IGNORED_ROOT.has(entry.name)) + if (!skip) { + stack.push(path.join(dir, entry.name)) + } + } else if (entry.isFile() && SOURCE_EXT.test(entry.name)) { + scanFile(path.join(dir, entry.name)) + } + } + } + } + + function isIgnoredPath(candidate: string): boolean { + const segments = toPosixPath(path.relative(projectRoot, candidate)).split('/') + if (segments[0] === '..') { + return true + } + if (IGNORED_ROOT.has(segments[0])) { + return true + } + return segments.some((segment) => IGNORED_ANYWHERE.has(segment) || segment.startsWith('.')) + } + + function startWatcher(): void { + let chokidar: { watch(root: string, options: unknown): FileWatcher } + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + chokidar = require('chokidar') + } catch { + try { + const metroDir = path.dirname(require.resolve('metro/package.json', { paths: [projectRoot] })) + // eslint-disable-next-line @typescript-eslint/no-require-imports + chokidar = require(require.resolve('chokidar', { paths: [metroDir] })) + } catch { + console.warn('[voltra:metro] chokidar unavailable - widget discovery is startup-scan only (no live updates)') + return + } + } + + watcher = chokidar.watch(projectRoot, { + ignoreInitial: true, + ignored: (candidate: string) => isIgnoredPath(candidate), + }) + + const onUpsert = (filePath: string) => { + if (!SOURCE_EXT.test(filePath)) { + return + } + try { + scanFile(filePath) + writeDevBarrels() + } catch (error) { + console.error(`[voltra:metro] ${error instanceof Error ? error.message : String(error)}`) + } + } + + watcher.on('add', onUpsert) + watcher.on('change', onUpsert) + watcher.on('unlink', (filePath: string) => { + removeSourcePath(filePath) + writeDevBarrels() + }) + } + + scanProject() + writeDevBarrels() + ready = true + startWatcher() + + return { + projectRoot, + getWidget(widgetId: string) { + return widgetsById.get(widgetId) || null + }, + isReady() { + return ready + }, + listWidgets() { + return Array.from(widgetsById.values()).map((widget) => ({ + id: widget.id, + componentName: widget.componentName, + exportName: widget.exportName, + sourcePath: toPosixPath(path.relative(projectRoot, widget.sourcePath)), + generatedEntryRelativePath: widget.generatedEntryRelativePath, + })) + }, + close() { + if (watcher) { + watcher.close() + watcher = null + } + }, + } +} diff --git a/packages/metro/tsconfig.base.json b/packages/metro/tsconfig.base.json new file mode 100644 index 00000000..4872c297 --- /dev/null +++ b/packages/metro/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "rootDir": "./src", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"] +} diff --git a/packages/metro/tsconfig.cjs.json b/packages/metro/tsconfig.cjs.json new file mode 100644 index 00000000..a6b3ca9c --- /dev/null +++ b/packages/metro/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./build/cjs", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/metro/tsconfig.esm.json b/packages/metro/tsconfig.esm.json new file mode 100644 index 00000000..2bb18d33 --- /dev/null +++ b/packages/metro/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/esm", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/metro/tsconfig.json b/packages/metro/tsconfig.json new file mode 100644 index 00000000..09b0ecb8 --- /dev/null +++ b/packages/metro/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.esm.json" }, + { "path": "./tsconfig.cjs.json" }, + { "path": "./tsconfig.types.json" } + ] +} diff --git a/packages/metro/tsconfig.typecheck.json b/packages/metro/tsconfig.typecheck.json new file mode 100644 index 00000000..77b33299 --- /dev/null +++ b/packages/metro/tsconfig.typecheck.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "rootDir": "../..", + "baseUrl": "../..", + "paths": { + "@use-voltra/android": ["packages/android/src/index.ts"], + "@use-voltra/android-client": ["packages/android-client/src/index.ts"], + "@use-voltra/android/client": ["packages/android-client/src/index.ts"], + "@use-voltra/android/server": ["packages/android/src/server.ts"], + "@use-voltra/android-server": ["packages/android-server/src/index.ts"], + "@use-voltra/compiler": ["packages/compiler/src/index.ts"], + "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/ios": ["packages/ios/src/index.ts"], + "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], + "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], + "@use-voltra/ios/server": ["packages/ios/src/server.ts"], + "@use-voltra/ios-server": ["packages/ios-server/src/index.ts"], + "@use-voltra/server": ["packages/server/src/index.ts"] + } + } +} diff --git a/packages/metro/tsconfig.types.json b/packages/metro/tsconfig.types.json new file mode 100644 index 00000000..8ec821ee --- /dev/null +++ b/packages/metro/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/types", + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ef98cc4..b0d446da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@use-voltra/ios-server': specifier: workspace:* version: link:../packages/ios-server + '@use-voltra/metro': + specifier: workspace:* + version: link:../packages/metro '@use-voltra/server': specifier: workspace:* version: link:../packages/server @@ -318,6 +321,15 @@ importers: specifier: ^7.20.5 version: 7.20.5 + packages/compiler: + dependencies: + '@babel/parser': + specifier: ^7.27.4 + version: 7.29.3 + '@babel/types': + specifier: ^7.27.4 + version: 7.29.0 + packages/core: dependencies: react: @@ -394,6 +406,9 @@ importers: '@expo/plist': specifier: ^0.3.5 version: 0.3.5 + '@use-voltra/compiler': + specifier: workspace:^ + version: link:../compiler '@use-voltra/expo-plugin': specifier: workspace:^ version: link:../expo-plugin @@ -444,6 +459,27 @@ importers: specifier: '*' version: 19.2.4 + packages/metro: + dependencies: + '@use-voltra/compiler': + specifier: workspace:^ + version: link:../compiler + expo: + specifier: '*' + version: 55.0.25(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.15)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + metro: + specifier: '*' + version: 0.83.7 + metro-config-transformers: + specifier: ^1.0.0 + version: 1.0.0(metro-config@0.83.7) + react: + specifier: '*' + version: 19.2.4 + react-native: + specifier: '*' + version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4) + packages/server: {} website: @@ -8050,6 +8086,12 @@ packages: { integrity: sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg== } engines: { node: '>=20.19.4' } + metro-config-transformers@1.0.0: + resolution: + { integrity: sha512-aF8bEBf9ocxWHpIbh95PGRTgk2C4gNM2scq/2uA87xl4vRoPr78AFz/bSh666FN3ak1LW3K5qkO5e6uolQeYYg== } + peerDependencies: + metro-config: '*' + metro-config@0.83.7: resolution: { integrity: sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q== } @@ -18885,6 +18927,10 @@ snapshots: transitivePeerDependencies: - supports-color + metro-config-transformers@1.0.0(metro-config@0.83.7): + dependencies: + metro-config: 0.83.7 + metro-config@0.83.7: dependencies: connect: 3.7.0 diff --git a/tsconfig.json b/tsconfig.json index 9a268a1c..a6c4e8ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,10 @@ "@use-voltra/android/internal": ["./packages/android/src/internal.ts"], "@use-voltra/android/server": ["./packages/android/src/server.ts"], "@use-voltra/android-server": ["./packages/android-server/src/index.ts"], + "@use-voltra/compiler": ["./packages/compiler/src/index.ts"], "@use-voltra/core": ["./packages/core/src/index.ts"], + "@use-voltra/metro": ["./packages/metro/src/index.ts"], + "@use-voltra/metro/scanner": ["./packages/metro/src/scanner.ts"], "@use-voltra/ios": ["./packages/ios/src/index.ts"], "@use-voltra/ios-client": ["./packages/ios-client/src/index.ts"], "@use-voltra/ios/client": ["./packages/ios-client/src/index.ts"],