@use-voltra/expo-plugin 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/build/cjs/constants.js +1 -3
  2. package/build/cjs/constants.js.map +1 -1
  3. package/build/cjs/dynamic-live-activity.js +6 -0
  4. package/build/cjs/dynamic-live-activity.js.map +1 -0
  5. package/build/cjs/index.js +15 -2
  6. package/build/cjs/index.js.map +1 -1
  7. package/build/cjs/serverUpdate.js +119 -0
  8. package/build/cjs/serverUpdate.js.map +1 -0
  9. package/build/cjs/utils/packageVersion.js +49 -0
  10. package/build/cjs/utils/packageVersion.js.map +1 -0
  11. package/build/cjs/utils/prerender.js +25 -159
  12. package/build/cjs/utils/prerender.js.map +1 -1
  13. package/build/cjs/widgetServerUpdate.js +63 -0
  14. package/build/cjs/widgetServerUpdate.js.map +1 -0
  15. package/build/esm/constants.js +0 -2
  16. package/build/esm/constants.js.map +1 -1
  17. package/build/esm/dynamic-live-activity.js +2 -0
  18. package/build/esm/dynamic-live-activity.js.map +1 -0
  19. package/build/esm/index.js +6 -2
  20. package/build/esm/index.js.map +1 -1
  21. package/build/esm/serverUpdate.js +112 -0
  22. package/build/esm/serverUpdate.js.map +1 -0
  23. package/build/esm/utils/packageVersion.js +13 -0
  24. package/build/esm/utils/packageVersion.js.map +1 -0
  25. package/build/esm/utils/prerender.js +24 -126
  26. package/build/esm/utils/prerender.js.map +1 -1
  27. package/build/esm/widgetServerUpdate.js +59 -0
  28. package/build/esm/widgetServerUpdate.js.map +1 -0
  29. package/build/types/constants.d.ts +0 -2
  30. package/build/types/constants.d.ts.map +1 -1
  31. package/build/types/dynamic-live-activity.d.ts +2 -0
  32. package/build/types/dynamic-live-activity.d.ts.map +1 -0
  33. package/build/types/index.d.ts +11 -4
  34. package/build/types/index.d.ts.map +1 -1
  35. package/build/types/serverUpdate.d.ts +80 -0
  36. package/build/types/serverUpdate.d.ts.map +1 -0
  37. package/build/types/types.d.ts +22 -0
  38. package/build/types/types.d.ts.map +1 -1
  39. package/build/types/utils/packageVersion.d.ts +2 -0
  40. package/build/types/utils/packageVersion.d.ts.map +1 -0
  41. package/build/types/utils/prerender.d.ts +22 -8
  42. package/build/types/utils/prerender.d.ts.map +1 -1
  43. package/build/types/widgetServerUpdate.d.ts +34 -0
  44. package/build/types/widgetServerUpdate.d.ts.map +1 -0
  45. package/package.json +3 -3
  46. package/src/constants.ts +0 -3
  47. package/src/dynamic-live-activity.node.test.ts +18 -0
  48. package/src/dynamic-live-activity.ts +1 -0
  49. package/src/index.ts +33 -3
  50. package/src/serverUpdate.node.test.ts +126 -0
  51. package/src/serverUpdate.ts +160 -0
  52. package/src/types.ts +25 -0
  53. package/src/utils/packageVersion.node.test.ts +24 -0
  54. package/src/utils/packageVersion.ts +19 -0
  55. package/src/utils/prerender.node.test.ts +2 -1
  56. package/src/utils/prerender.ts +34 -155
  57. package/src/widgetServerUpdate.ts +109 -0
@@ -1,11 +1,7 @@
1
- import fs from 'node:fs'
2
1
  import path from 'node:path'
3
- import { createRequire } from 'node:module'
4
- import vm from 'node:vm'
5
2
 
6
- import * as babel from '@babel/core'
3
+ import { createWidgetModuleLoader, type WidgetModuleLoader, type WidgetModulePlatform } from '@use-voltra/compiler'
7
4
 
8
- import { MODULE_EXTENSIONS } from '../constants'
9
5
  import type { WidgetInitialStatePath, WidgetLabel } from '../types'
10
6
  import { logger } from './logger'
11
7
  import { isWidgetLocalizedMap } from './widgetLabel'
@@ -26,170 +22,51 @@ export interface PrerenderableWidget {
26
22
  /** widgetId -> locale key -> prerendered JSON string (single-file widgets use `__default`) */
27
23
  export type PrerenderedWidgetStates = Map<string, Map<string, string>>
28
24
 
29
- const PRERENDER_PACKAGE_REDIRECTS: Record<string, string> = {
30
- '@use-voltra/ios-client': '@use-voltra/ios',
31
- '@use-voltra/android-client': '@use-voltra/android',
25
+ export interface WidgetModuleEvaluationOptions {
26
+ projectRoot: string
27
+ /** Platform being prebuilt. Determines `Platform.OS` inside widget code. */
28
+ platform: WidgetModulePlatform
29
+ /** Reuse a loader across several widgets so warnings are reported once per prebuild. */
30
+ loader?: WidgetModuleLoader
32
31
  }
33
32
 
34
33
  /**
35
- * Check if a module specifier is a relative or absolute path (local file)
36
- */
37
- function isLocalModule(moduleSpecifier: string): boolean {
38
- return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')
39
- }
40
-
41
- /**
42
- * Resolve a module path, trying different extensions
43
- */
44
- function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null {
45
- const basePath = path.resolve(fromDir, moduleSpecifier)
46
-
47
- for (const ext of MODULE_EXTENSIONS) {
48
- const fullPath = basePath + ext
49
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
50
- return fullPath
51
- }
52
- }
53
-
54
- // Try index files if it's a directory
55
- if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
56
- for (const ext of MODULE_EXTENSIONS) {
57
- const indexPath = path.join(basePath, 'index' + ext)
58
- if (fs.existsSync(indexPath)) {
59
- return indexPath
60
- }
61
- }
62
- }
63
-
64
- return null
65
- }
66
-
67
- function getProjectBabelConfigPath(projectRoot: string): string | null {
68
- const configPath = path.join(projectRoot, 'babel.config.js')
69
- return fs.existsSync(configPath) ? configPath : null
70
- }
71
-
72
- function getFallbackExpoPreset(projectRoot: string): string {
73
- return require.resolve('babel-preset-expo', { paths: [projectRoot] })
74
- }
75
-
76
- /**
77
- * Transpile a file with Babel
34
+ * Create the loader used to evaluate widget source during prebuild.
35
+ *
36
+ * Evaluation rules live in `@use-voltra/compiler` so that prebuild, `voltra apply`, and
37
+ * the Metro widget bundler agree on what widget code may import.
78
38
  */
79
- function transpileFile(filePath: string, projectRoot: string): string {
80
- const code = fs.readFileSync(filePath, 'utf8')
81
- const projectBabelConfigPath = getProjectBabelConfigPath(projectRoot)
82
-
83
- const result = babel.transformSync(code, {
84
- cwd: projectRoot,
85
- filename: filePath,
86
- ...(projectBabelConfigPath
87
- ? { configFile: projectBabelConfigPath }
88
- : {
89
- babelrc: false,
90
- configFile: false,
91
- presets: [getFallbackExpoPreset(projectRoot)],
92
- }),
39
+ export function createPrerenderWidgetModuleLoader(
40
+ projectRoot: string,
41
+ platform: WidgetModulePlatform
42
+ ): WidgetModuleLoader {
43
+ return createWidgetModuleLoader({
44
+ projectRoot,
45
+ platform,
46
+ onWarning: (message) => logger.warn(message),
93
47
  })
48
+ }
94
49
 
95
- if (!result || !result.code) {
96
- throw new Error(`Babel transpilation failed for ${filePath}`)
97
- }
98
-
99
- return result.code
50
+ function resolveLoader({ projectRoot, platform, loader }: WidgetModuleEvaluationOptions): WidgetModuleLoader {
51
+ return loader ?? createPrerenderWidgetModuleLoader(projectRoot, platform)
100
52
  }
101
53
 
102
54
  /**
103
- * Evaluate a widget module using Babel transpilation and Node.js VM.
104
- * This allows executing widget code that uses JSX and React components.
105
- * Local module dependencies are also transpiled with the same Babel settings.
55
+ * Evaluate a widget module and return its exports object.
106
56
  *
107
57
  * Exported so platform-specific prerender flows can reuse the same module loader rather
108
- * than duplicating the Babel + VM scaffolding. The returned value is the module's exports
109
- * object — callers decide whether to read `.default`, a named export, etc.
58
+ * than duplicating the Babel + VM scaffolding. Callers decide whether to read `.default`,
59
+ * a named export, etc.
110
60
  */
111
- export function evaluateWidgetModuleExports(
112
- projectRoot: string,
113
- filePath: string,
114
- warnedRedirects = new Set<string>()
115
- ): any {
116
- // Cache for already-evaluated modules to handle circular dependencies
117
- const moduleCache = new Map<string, any>()
118
- const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
119
-
120
- /**
121
- * Custom require that transpiles local modules with Babel
122
- */
123
- function customRequire(moduleSpecifier: string, currentDir: string): any {
124
- // For non-local modules (npm packages), use native require
125
- if (!isLocalModule(moduleSpecifier)) {
126
- const redirectedSpecifier = PRERENDER_PACKAGE_REDIRECTS[moduleSpecifier]
127
-
128
- if (redirectedSpecifier) {
129
- if (!warnedRedirects.has(moduleSpecifier)) {
130
- warnedRedirects.add(moduleSpecifier)
131
- logger.warn(
132
- `Prerendering initial state imported '${moduleSpecifier}'. Using '${redirectedSpecifier}' instead.`
133
- )
134
- }
135
-
136
- return projectRequire(redirectedSpecifier)
137
- }
138
-
139
- return projectRequire(moduleSpecifier)
140
- }
141
-
142
- // Resolve the local module path
143
- const resolvedPath = resolveModulePath(moduleSpecifier, currentDir)
144
- if (!resolvedPath) {
145
- throw new Error(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`)
146
- }
147
-
148
- // Return cached module if already evaluated
149
- if (moduleCache.has(resolvedPath)) {
150
- return moduleCache.get(resolvedPath)
151
- }
152
-
153
- // Transpile and evaluate the module
154
- const transpiledCode = transpileFile(resolvedPath, projectRoot)
155
- const moduleDir = path.dirname(resolvedPath)
156
-
157
- const mockModule = { exports: {} as any }
158
-
159
- // Create require function bound to the module's directory
160
- const boundRequire = (spec: string) => customRequire(spec, moduleDir)
161
-
162
- const context = vm.createContext({
163
- exports: mockModule.exports,
164
- module: mockModule,
165
- require: boundRequire,
166
- __filename: resolvedPath,
167
- __dirname: moduleDir,
168
- console: console,
169
- process: process,
170
- })
171
-
172
- // Cache before evaluation to handle circular dependencies
173
- moduleCache.set(resolvedPath, mockModule.exports)
174
-
175
- const script = new vm.Script(transpiledCode, { filename: resolvedPath })
176
- script.runInContext(context)
177
-
178
- // Update cache with final exports (in case module.exports was reassigned)
179
- moduleCache.set(resolvedPath, mockModule.exports)
180
-
181
- return mockModule.exports
182
- }
183
-
184
- return customRequire(filePath, path.dirname(filePath))
61
+ export function evaluateWidgetModuleExports(filePath: string, options: WidgetModuleEvaluationOptions): any {
62
+ return resolveLoader(options).load(filePath)
185
63
  }
186
64
 
187
65
  /**
188
66
  * Evaluate a widget file as a server-style WidgetVariants module and return its object export.
189
67
  */
190
- export function evaluateWidgetModule(projectRoot: string, filePath: string, warnedRedirects = new Set<string>()): any {
191
- const exports = evaluateWidgetModuleExports(projectRoot, filePath, warnedRedirects)
192
- const widgetVariants: any = exports.default || exports
68
+ export function evaluateWidgetModule(filePath: string, options: WidgetModuleEvaluationOptions): any {
69
+ const widgetVariants = resolveLoader(options).loadDefaultExport(filePath)
193
70
 
194
71
  if (!widgetVariants || typeof widgetVariants !== 'object') {
195
72
  throw new Error('Widget file must export a WidgetVariants object or have a default export of WidgetVariants')
@@ -208,15 +85,17 @@ export function evaluateWidgetModule(projectRoot: string, filePath: string, warn
208
85
  * @param widgets - Array of widget configurations
209
86
  * @param projectRoot - Root directory of the Expo project
210
87
  * @param renderer - The renderer function to use (voltra/server or voltra/android/server)
88
+ * @param platform - Platform being prebuilt
211
89
  * @returns Map of widgetId -> (locale key -> prerendered JSON string)
212
90
  */
213
91
  export async function prerenderWidgetState(
214
92
  widgets: PrerenderableWidget[],
215
93
  projectRoot: string,
216
- renderer: WidgetRenderer
94
+ renderer: WidgetRenderer,
95
+ platform: WidgetModulePlatform
217
96
  ): Promise<PrerenderedWidgetStates> {
218
97
  const prerenderedStates: PrerenderedWidgetStates = new Map()
219
- const warnedRedirects = new Set<string>()
98
+ const loader = createPrerenderWidgetModuleLoader(projectRoot, platform)
220
99
 
221
100
  for (const widget of widgets) {
222
101
  if (!widget.initialStatePath) {
@@ -233,7 +112,7 @@ export async function prerenderWidgetState(
233
112
  try {
234
113
  for (const [localeKey, relativePath] of Object.entries(perLocalePaths)) {
235
114
  const absoluteWidgetPath = path.resolve(projectRoot, relativePath)
236
- const widgetVariants = evaluateWidgetModule(projectRoot, absoluteWidgetPath, warnedRedirects)
115
+ const widgetVariants = evaluateWidgetModule(absoluteWidgetPath, { projectRoot, platform, loader })
237
116
  const prerenderedState = renderer(widgetVariants)
238
117
  inner.set(localeKey, prerenderedState)
239
118
  }
@@ -0,0 +1,109 @@
1
+ import { resolveServerUpdateInterval, resolveServerUpdateUrl, validateServerUpdateRefresh } from './serverUpdate'
2
+ import { logger } from './utils/logger'
3
+
4
+ /**
5
+ * Expo-plugin side of the `serverUpdate` config rules. The rules themselves live in
6
+ * `./serverUpdate`, which the `voltra` CLI mirrors; this module only turns them into the plugin's
7
+ * thrown errors and console warnings.
8
+ */
9
+
10
+ /** `serverUpdate` as it appears in app.json, before defaults are applied. */
11
+ export interface WidgetServerUpdateConfig {
12
+ url?: string
13
+ intervalMinutes?: number
14
+ refresh?: boolean
15
+ }
16
+
17
+ /** `serverUpdate` after defaults, as the generators consume it. */
18
+ export interface ResolvedWidgetServerUpdateConfig {
19
+ /** Absent when app.json set no URL — the app supplies one at runtime. */
20
+ url?: string
21
+ intervalMinutes: number
22
+ refresh: boolean
23
+ }
24
+
25
+ export interface WidgetServerUpdateRules {
26
+ /** True when the widget has an `entry` and so renders bundled JS from fetched props. */
27
+ hasEntry: boolean
28
+ /** Interval used when the widget sets none. Ignored for widgets with `entry`. */
29
+ defaultIntervalMinutes: number
30
+ /** Platform floor for payload widgets. Ignored for widgets with `entry`. */
31
+ minimumIntervalMinutes: number
32
+ }
33
+
34
+ export function validateWidgetServerUpdate(
35
+ serverUpdate: unknown,
36
+ widgetId: string,
37
+ rules: WidgetServerUpdateRules
38
+ ): void {
39
+ if (serverUpdate === undefined) {
40
+ return
41
+ }
42
+
43
+ const context = `Widget '${widgetId}': serverUpdate`
44
+
45
+ if (typeof serverUpdate !== 'object' || serverUpdate === null || Array.isArray(serverUpdate)) {
46
+ throw new Error(`${context} must be an object`)
47
+ }
48
+
49
+ const { url, intervalMinutes, refresh } = serverUpdate as WidgetServerUpdateConfig
50
+
51
+ const resolvedUrl = resolveServerUpdateUrl(url, context)
52
+
53
+ if (resolvedUrl.kind === 'invalid') {
54
+ throw new Error(resolvedUrl.error)
55
+ }
56
+
57
+ if (resolvedUrl.kind === 'insecure') {
58
+ logger.warn(resolvedUrl.warning)
59
+ }
60
+
61
+ const interval = resolveServerUpdateInterval({
62
+ intervalMinutes,
63
+ context,
64
+ hasEntry: rules.hasEntry,
65
+ defaultIntervalMinutes: rules.defaultIntervalMinutes,
66
+ minimumIntervalMinutes: rules.minimumIntervalMinutes,
67
+ })
68
+
69
+ if (interval.kind === 'invalid') {
70
+ throw new Error(interval.error)
71
+ }
72
+
73
+ if (interval.kind === 'clamped') {
74
+ logger.warn(interval.warning)
75
+ }
76
+
77
+ const refreshError = validateServerUpdateRefresh(refresh, context)
78
+
79
+ if (refreshError) {
80
+ throw new Error(refreshError)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Applies defaults to a validated `serverUpdate`. Generators call this instead of reading
86
+ * `intervalMinutes` directly, so the value written into a plist or a generated asset is the one
87
+ * the widget will actually be scheduled on.
88
+ */
89
+ export function resolveWidgetServerUpdate(
90
+ serverUpdate: WidgetServerUpdateConfig,
91
+ rules: WidgetServerUpdateRules
92
+ ): ResolvedWidgetServerUpdateConfig {
93
+ const interval = resolveServerUpdateInterval({
94
+ intervalMinutes: serverUpdate.intervalMinutes,
95
+ context: 'serverUpdate',
96
+ hasEntry: rules.hasEntry,
97
+ defaultIntervalMinutes: rules.defaultIntervalMinutes,
98
+ minimumIntervalMinutes: rules.minimumIntervalMinutes,
99
+ })
100
+
101
+ return {
102
+ url: serverUpdate.url,
103
+ // An invalid interval cannot reach here — validateWidgetServerUpdate throws on it first — but
104
+ // if the two are ever called out of order, falling back to the platform's own default is less
105
+ // surprising than silently switching a payload widget to the Dynamic one.
106
+ intervalMinutes: interval.kind === 'invalid' ? rules.defaultIntervalMinutes : interval.intervalMinutes,
107
+ refresh: serverUpdate.refresh === true,
108
+ }
109
+ }