|
| 1 | +import path from 'path'; |
| 2 | +import fs from 'fs'; |
| 3 | +import type { Plugin, ResolvedConfig } from 'vite'; |
| 4 | +import { getProjectRootPath } from './project.js'; |
| 5 | +import { getProjectAppRelativePath } from './utils.js'; |
| 6 | + |
| 7 | +const projectRoot = getProjectRootPath(); |
| 8 | + |
| 9 | +export interface AppComponentsOptions { |
| 10 | + /** |
| 11 | + * List of app component paths (relative to project root). |
| 12 | + * These are typically custom Android Activity or Application classes. |
| 13 | + * Example: ['./app/custom-activity.android.ts', './app/custom-application.android.ts'] |
| 14 | + */ |
| 15 | + appComponents?: string[]; |
| 16 | + platform: 'android' | 'ios' | 'visionos'; |
| 17 | + verbose?: boolean; |
| 18 | +} |
| 19 | + |
| 20 | +/** |
| 21 | + * Get app components from environment variable or nativescript.config.ts |
| 22 | + * Format: comma-separated paths, e.g., "./app/custom-activity.android,./app/custom-application.android" |
| 23 | + */ |
| 24 | +function getAppComponentsFromEnv(): string[] { |
| 25 | + const envValue = process.env.NS_APP_COMPONENTS; |
| 26 | + if (!envValue) return []; |
| 27 | + return envValue |
| 28 | + .split(',') |
| 29 | + .map((p) => p.trim()) |
| 30 | + .filter(Boolean); |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Resolve an app component path to an absolute path |
| 35 | + */ |
| 36 | +function resolveComponentPath(componentPath: string): string | null { |
| 37 | + // If already absolute, check if exists |
| 38 | + if (path.isAbsolute(componentPath)) { |
| 39 | + return fs.existsSync(componentPath) ? componentPath : null; |
| 40 | + } |
| 41 | + |
| 42 | + // Remove leading ./ if present for consistency |
| 43 | + const cleanPath = componentPath.replace(/^\.\//, ''); |
| 44 | + |
| 45 | + // Try with and without extensions |
| 46 | + const extensions = ['', '.ts', '.js', '.android.ts', '.android.js']; |
| 47 | + |
| 48 | + for (const ext of extensions) { |
| 49 | + const fullPath = path.resolve(projectRoot, cleanPath + ext); |
| 50 | + if (fs.existsSync(fullPath)) { |
| 51 | + return fullPath; |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + // Also try in the app directory |
| 56 | + const appDir = getProjectAppRelativePath(''); |
| 57 | + for (const ext of extensions) { |
| 58 | + const fullPath = path.resolve(projectRoot, appDir, cleanPath + ext); |
| 59 | + if (fs.existsSync(fullPath)) { |
| 60 | + return fullPath; |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + return null; |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Extract the output name for an app component |
| 69 | + * e.g., "./app/custom-activity.android.ts" -> "custom-activity" |
| 70 | + * e.g., "./app/custom-application.android.ts" -> "custom-application" |
| 71 | + */ |
| 72 | +function getComponentOutputName(componentPath: string): string { |
| 73 | + const basename = path.basename(componentPath); |
| 74 | + // Remove .android.ts, .android.js, .ts, .js extensions |
| 75 | + return basename.replace(/\.(android\.)?(ts|js)$/, ''); |
| 76 | +} |
| 77 | + |
| 78 | +/** |
| 79 | + * Plugin to handle NativeScript app components (custom Activity/Application classes) |
| 80 | + * |
| 81 | + * These components need to be bundled as separate entry points because: |
| 82 | + * 1. Custom Android Activity classes are loaded by the Android runtime before the main bundle |
| 83 | + * 2. Custom Android Application classes are loaded even earlier in the app lifecycle |
| 84 | + * |
| 85 | + * Usage in vite.config.ts: |
| 86 | + * ```ts |
| 87 | + * import { defineConfig } from 'vite'; |
| 88 | + * import { typescriptConfig, appComponentsPlugin } from '@nativescript/vite'; |
| 89 | + * |
| 90 | + * export default defineConfig(({ mode }) => { |
| 91 | + * const config = typescriptConfig({ mode }); |
| 92 | + * config.plugins.push( |
| 93 | + * appComponentsPlugin({ |
| 94 | + * appComponents: ['./app/custom-activity.android.ts'], |
| 95 | + * platform: 'android' |
| 96 | + * }) |
| 97 | + * ); |
| 98 | + * return config; |
| 99 | + * }); |
| 100 | + * ``` |
| 101 | + * |
| 102 | + * Or via environment variable: |
| 103 | + * NS_APP_COMPONENTS="./app/custom-activity.android,./app/custom-application.android" ns run android |
| 104 | + */ |
| 105 | +export function appComponentsPlugin(options: AppComponentsOptions): Plugin { |
| 106 | + const { platform, verbose = false } = options; |
| 107 | + |
| 108 | + // Collect app components from all sources |
| 109 | + let appComponents: string[] = [...(options.appComponents || []), ...getAppComponentsFromEnv()]; |
| 110 | + |
| 111 | + // Remove duplicates |
| 112 | + appComponents = [...new Set(appComponents)]; |
| 113 | + |
| 114 | + // Resolve all component paths |
| 115 | + const resolvedComponents: Map<string, { absolutePath: string; outputName: string }> = new Map(); |
| 116 | + |
| 117 | + for (const component of appComponents) { |
| 118 | + const absolutePath = resolveComponentPath(component); |
| 119 | + if (absolutePath) { |
| 120 | + const outputName = getComponentOutputName(absolutePath); |
| 121 | + resolvedComponents.set(component, { absolutePath, outputName }); |
| 122 | + if (verbose) { |
| 123 | + console.log(`[app-components] Found: ${component} -> ${outputName}.mjs`); |
| 124 | + } |
| 125 | + } else if (verbose) { |
| 126 | + console.warn(`[app-components] Could not resolve: ${component}`); |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + // Skip if no components found |
| 131 | + if (resolvedComponents.size === 0) { |
| 132 | + return { |
| 133 | + name: 'nativescript-app-components', |
| 134 | + apply: 'build', |
| 135 | + }; |
| 136 | + } |
| 137 | + |
| 138 | + // Track component output names for entryFileNames |
| 139 | + const componentOutputNames = new Set<string>(); |
| 140 | + for (const [, { outputName }] of resolvedComponents) { |
| 141 | + componentOutputNames.add(outputName); |
| 142 | + } |
| 143 | + |
| 144 | + // Set environment variable so main-entry.ts can inject imports for these components |
| 145 | + // This allows the virtual module to know which app components are configured |
| 146 | + const componentPaths = Array.from(resolvedComponents.values()).map((c) => c.absolutePath); |
| 147 | + process.env.NS_APP_COMPONENTS = componentPaths.join(','); |
| 148 | + |
| 149 | + // Create a set of output names for quick lookup in resolveId |
| 150 | + const outputMjsFiles = new Set<string>(); |
| 151 | + const absoluteMjsPaths = new Set<string>(); |
| 152 | + for (const [, { outputName }] of resolvedComponents) { |
| 153 | + outputMjsFiles.add(`~/${outputName}.mjs`); |
| 154 | + outputMjsFiles.add(`./${outputName}.mjs`); |
| 155 | + // Also track absolute paths that Vite might resolve ~/foo.mjs to |
| 156 | + const appDir = getProjectAppRelativePath(''); |
| 157 | + const absoluteMjsPath = path.resolve(projectRoot, appDir, `${outputName}.mjs`); |
| 158 | + absoluteMjsPaths.add(absoluteMjsPath); |
| 159 | + } |
| 160 | + |
| 161 | + let config: ResolvedConfig; |
| 162 | + |
| 163 | + return { |
| 164 | + name: 'nativescript-app-components', |
| 165 | + apply: 'build', |
| 166 | + |
| 167 | + configResolved(resolvedConfig) { |
| 168 | + config = resolvedConfig; |
| 169 | + }, |
| 170 | + |
| 171 | + // Mark app component output files as external during build |
| 172 | + // These are generated as separate entry points and will exist at runtime |
| 173 | + resolveId(id) { |
| 174 | + // Handle ~/foo.mjs or ./foo.mjs patterns |
| 175 | + if (outputMjsFiles.has(id)) { |
| 176 | + // Return the id with external flag - this tells Rollup to keep the import as-is |
| 177 | + return { id: `./${id.replace(/^~\//, '')}`, external: true }; |
| 178 | + } |
| 179 | + // Handle absolute paths that Vite resolves ~/foo.mjs to (e.g., /path/to/app/foo.mjs) |
| 180 | + if (absoluteMjsPaths.has(id)) { |
| 181 | + const basename = path.basename(id); |
| 182 | + return { id: `./${basename}`, external: true }; |
| 183 | + } |
| 184 | + return null; |
| 185 | + }, |
| 186 | + |
| 187 | + // Modify the Vite config to support multiple entry points |
| 188 | + config(userConfig) { |
| 189 | + if (resolvedComponents.size === 0) return null; |
| 190 | + |
| 191 | + // We need to modify the output.entryFileNames to handle multiple entries |
| 192 | + return { |
| 193 | + build: { |
| 194 | + rollupOptions: { |
| 195 | + output: { |
| 196 | + // Use a function to determine entry file names |
| 197 | + entryFileNames: (chunkInfo: { name: string }) => { |
| 198 | + // App components should output as .mjs files |
| 199 | + // This is required because SBG (Static Binding Generator) only parses |
| 200 | + // .mjs files as ES modules. If we output as .js, SBG will try to parse |
| 201 | + // it as CommonJS and fail on import statements. |
| 202 | + if (componentOutputNames.has(chunkInfo.name)) { |
| 203 | + return `${chunkInfo.name}.mjs`; |
| 204 | + } |
| 205 | + // Default: main bundle |
| 206 | + return 'bundle.mjs'; |
| 207 | + }, |
| 208 | + }, |
| 209 | + }, |
| 210 | + }, |
| 211 | + }; |
| 212 | + }, |
| 213 | + |
| 214 | + // Modify rollup options to add additional entry points |
| 215 | + options(inputOptions) { |
| 216 | + if (resolvedComponents.size === 0) return null; |
| 217 | + |
| 218 | + // Get current input |
| 219 | + const currentInput = inputOptions.input; |
| 220 | + const newInput: Record<string, string> = {}; |
| 221 | + |
| 222 | + // Preserve existing inputs |
| 223 | + if (typeof currentInput === 'string') { |
| 224 | + newInput['bundle'] = currentInput; |
| 225 | + } else if (Array.isArray(currentInput)) { |
| 226 | + currentInput.forEach((input, i) => { |
| 227 | + newInput[`entry${i}`] = input; |
| 228 | + }); |
| 229 | + } else if (currentInput && typeof currentInput === 'object') { |
| 230 | + Object.assign(newInput, currentInput); |
| 231 | + } |
| 232 | + |
| 233 | + // Add app component entries - use the actual file path directly |
| 234 | + for (const [, { absolutePath, outputName }] of resolvedComponents) { |
| 235 | + newInput[outputName] = absolutePath; |
| 236 | + } |
| 237 | + |
| 238 | + if (verbose) { |
| 239 | + console.log('[app-components] Build inputs:', newInput); |
| 240 | + } |
| 241 | + |
| 242 | + return { ...inputOptions, input: newInput }; |
| 243 | + }, |
| 244 | + |
| 245 | + // Adjust output file names for app components (fallback in case entryFileNames doesn't work) |
| 246 | + generateBundle(options, bundle) { |
| 247 | + for (const [fileName, chunk] of Object.entries(bundle)) { |
| 248 | + if (chunk.type !== 'chunk') continue; |
| 249 | + |
| 250 | + // Check if this is an app component entry |
| 251 | + if (componentOutputNames.has(chunk.name)) { |
| 252 | + // Rename to .mjs (SBG requires .mjs for ES module parsing) |
| 253 | + const newFileName = `${chunk.name}.mjs`; |
| 254 | + if (fileName !== newFileName) { |
| 255 | + chunk.fileName = newFileName; |
| 256 | + delete bundle[fileName]; |
| 257 | + bundle[newFileName] = chunk; |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + }, |
| 262 | + |
| 263 | + // Post-process app component chunks to fix Rollup's internal variable renaming. |
| 264 | + // SBG (Static Binding Generator) needs the __extends and __decorate calls to use |
| 265 | + // the same class name as the outer variable assignment. |
| 266 | + renderChunk(code, chunk) { |
| 267 | + // Only process app component chunks |
| 268 | + if (!componentOutputNames.has(chunk.name)) { |
| 269 | + return null; |
| 270 | + } |
| 271 | + |
| 272 | + // Look for patterns where Rollup renamed the internal class variable |
| 273 | + // Pattern: var ClassName = ... __extends(ClassName2, _super); ... return ClassName2; ... |
| 274 | + // We need: var ClassName = ... __extends(ClassName, _super); ... return ClassName; ... |
| 275 | + |
| 276 | + // Use a simpler regex that matches across the various output formats |
| 277 | + // This finds: var SomeName = ... __extends(SomeName2, ...) |
| 278 | + const varAssignRegex = /var\s+(\w+)\s*=[\s\S]*?__extends\s*\(\s*(\w+)\s*,/g; |
| 279 | + |
| 280 | + let match; |
| 281 | + let modifiedCode = code; |
| 282 | + |
| 283 | + while ((match = varAssignRegex.exec(code)) !== null) { |
| 284 | + const outerName = match[1]; // e.g., "CustomActivity" |
| 285 | + const innerName = match[2]; // e.g., "CustomActivity2" |
| 286 | + |
| 287 | + if (outerName !== innerName && innerName === outerName + '2') { |
| 288 | + // Rollup renamed it - fix by replacing all occurrences of the inner name |
| 289 | + // Only within this chunk, replace innerName with outerName |
| 290 | + // Be careful to only replace as a complete identifier |
| 291 | + const innerNameRegex = new RegExp(`\\b${innerName}\\b`, 'g'); |
| 292 | + modifiedCode = modifiedCode.replace(innerNameRegex, outerName); |
| 293 | + |
| 294 | + if (verbose) { |
| 295 | + console.log(`[app-components] Fixed Rollup rename: ${innerName} -> ${outerName} in ${chunk.fileName}`); |
| 296 | + } |
| 297 | + } |
| 298 | + } |
| 299 | + |
| 300 | + if (modifiedCode !== code) { |
| 301 | + return { code: modifiedCode, map: null }; |
| 302 | + } |
| 303 | + |
| 304 | + return null; |
| 305 | + }, |
| 306 | + }; |
| 307 | +} |
| 308 | + |
| 309 | +/** |
| 310 | + * Get resolved app components with their output file names |
| 311 | + * Used by main-entry.ts to inject imports for custom activities/applications |
| 312 | + */ |
| 313 | +export function getResolvedAppComponents(platform: string): Array<{ absolutePath: string; outputName: string }> { |
| 314 | + // Get components from environment variable (set by appComponentsPlugin during build) |
| 315 | + const components = getAppComponentsFromEnv(); |
| 316 | + const resolved: Array<{ absolutePath: string; outputName: string }> = []; |
| 317 | + |
| 318 | + for (const component of components) { |
| 319 | + const absolutePath = resolveComponentPath(component); |
| 320 | + if (absolutePath) { |
| 321 | + const outputName = getComponentOutputName(absolutePath); |
| 322 | + resolved.push({ absolutePath, outputName }); |
| 323 | + } |
| 324 | + } |
| 325 | + |
| 326 | + return resolved; |
| 327 | +} |
0 commit comments