UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

683 lines (598 loc) 22.7 kB
import events from 'node:events' import path from 'node:path' import { configureVXRNCompilerPlugin } from '@vxrn/compiler' import { resolvePath } from '@vxrn/resolve' import type { ExpoManifestRequestHandlerPluginPluginOptions, MetroPluginOptions, } from '@vxrn/vite-plugin-metro' import type { Plugin, PluginOption } from 'vite' import { barrel } from 'vite-plugin-barrel' import tsconfigPaths from 'vite-tsconfig-paths' import { autoDepOptimizePlugin, getOptionsFilled, loadEnv } from 'vxrn' import vxrnVitePlugin from 'vxrn/vite-plugin' import { CACHE_KEY } from '../constants' import { getViteMetroPluginOptions } from '../metro-config/getViteMetroPluginOptions' import '../polyfills-server' import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions' import { ensureTSConfig } from './ensureTsConfig' import { setOneOptions } from './loadConfig' import { clientTreeShakePlugin } from './plugins/clientTreeShakePlugin' import { createFileSystemRouterPlugin } from './plugins/fileSystemRouterPlugin' import { fixDependenciesPlugin } from './plugins/fixDependenciesPlugin' import { generateFileSystemRouteTypesPlugin } from './plugins/generateFileSystemRouteTypesPlugin' import { imageDataPlugin } from './plugins/imageDataPlugin' import { SSRCSSPlugin } from './plugins/SSRCSSPlugin' import { virtualEntryId } from './plugins/virtualEntryConstants' import { createVirtualEntry } from './plugins/virtualEntryPlugin' import type { One } from './types' type MetroOptions = MetroPluginOptions /** * This needs a big refactor! * I guess these plugins are all being loaded by native?? * At least the react compiler plugin is applying to native, so the entire premise of some things * here are wrong. we can probably refactor and merge all the stuff */ events.setMaxListeners(1_000) // temporary for tamagui plugin compat globalThis.__vxrnEnableNativeEnv = true // temporary until we fix double-load issue, which means we'd have to somehow // not control the port/host from our config, but still pass it into ENV // until then we want to avoid double loading everything on first start export function one(options: One.PluginOptions = {}): PluginOption { const routerRoot = getRouterRootFromOneOptions(options) /** * A non-null value means that we are going to use Metro. */ const metroOptions: | (MetroOptions & ExpoManifestRequestHandlerPluginPluginOptions) | null = (() => { if (options.native?.bundler !== 'metro' && !process.env.ONE_METRO_MODE) return null if (process.env.ONE_METRO_MODE) { console.info('ONE_METRO_MODE environment variable is set, enabling Metro mode') } const routerRoot = getRouterRootFromOneOptions(options) const defaultMetroOptions = getViteMetroPluginOptions({ projectRoot: process.cwd(), // TODO: hard-coded process.cwd(), we should make this optional since the plugin can have a default to vite's `config.root`. relativeRouterRoot: routerRoot, ignoredRouteFiles: options.router?.ignoredRouteFiles, userDefaultConfigOverrides: (options.native?.bundlerOptions as any) ?.defaultConfigOverrides, setupFile: options.setupFile, }) const userMetroOptions = options.native?.bundlerOptions as typeof defaultMetroOptions const babelConfig = { ...defaultMetroOptions?.babelConfig, ...userMetroOptions?.babelConfig, } // TODO: [METRO-OPTIONS-MERGING] We only do shallow merge here. return { ...defaultMetroOptions, ...userMetroOptions, defaultConfigOverrides: defaultMetroOptions?.defaultConfigOverrides, // defaultConfigOverrides is merged by getViteMetroPluginOptions, so we need to set it here again. argv: { ...defaultMetroOptions?.argv, ...userMetroOptions?.argv, }, babelConfig: { ...babelConfig, plugins: [ ...(babelConfig.plugins || []), ...(options.react?.compiler === true || options.react?.compiler === 'native' ? ['babel-plugin-react-compiler'] : []), ], }, mainModuleName: 'one/metro-entry', // So users won't need to write `"main": "one/metro-entry"` in their `package.json` like ordinary Expo apps. } })() const vxrnPlugins: PluginOption[] = [] if (!process.env.IS_VXRN_CLI) { console.warn('Experimental: running VxRN as a Vite plugin. This is not yet stable.') vxrnPlugins.push( vxrnVitePlugin({ metro: metroOptions, }) ) } else { if (!globalThis.__oneOptions) { // first load we are just loading it ourselves to get the user options // so we can just set here and return nothing setOneOptions(options) globalThis['__vxrnPluginConfig__'] = options globalThis['__vxrnMetroOptions__'] = metroOptions return [] } } // ensure tsconfig if (options.config?.ensureTSConfig !== false) { void ensureTSConfig() } let tsConfigPathsPlugin: Plugin | null = null const vxrnOptions = getOptionsFilled() const root = vxrnOptions?.root || process.cwd() const barrelOption = options.optimization?.barrel const compiler = options.react?.compiler if (compiler) { configureVXRNCompilerPlugin({ enableCompiler: compiler === 'native' ? ['ios', 'android'] : compiler === 'web' ? ['ssr', 'client'] : true, }) } const autoDepsOptions = options.ssr?.autoDepsOptimization const devAndProdPlugins: Plugin[] = [ { name: 'one:config', __get: options, } as any, !barrelOption ? null : (barrel({ packages: Array.isArray(barrelOption) ? barrelOption : ['@tamagui/lucide-icons'], }) as any), imageDataPlugin(), { name: 'one-define-client-env', async config(userConfig) { const { clientEnvDefine } = await loadEnv( vxrnOptions?.mode ?? userConfig?.mode ?? 'development', process.cwd(), userConfig?.envPrefix ) return { define: { ...clientEnvDefine, ...(process.env.ONE_DEBUG_ROUTER && { 'process.env.ONE_DEBUG_ROUTER': JSON.stringify( process.env.ONE_DEBUG_ROUTER ), }), }, } }, }, ...(autoDepsOptions === false ? [] : [ autoDepOptimizePlugin({ onScannedDeps({ hasReanimated, hasNativewind }) { configureVXRNCompilerPlugin({ enableReanimated: hasReanimated, enableNativeCSS: options.native?.css ?? hasNativewind, enableNativewind: hasNativewind, }) }, root, include: /node_modules/, ...(autoDepsOptions === true ? {} : autoDepsOptions), }), ]), // proxy because you cant add a plugin inside a plugin new Proxy( { name: 'one:tsconfig-paths', config(configIncoming) { const pathsConfig = options.config?.tsConfigPaths if (pathsConfig === false) { return } if ( configIncoming.plugins ?.flat() .some((p) => p && (p as any)['name'] === 'vite-tsconfig-paths') ) { // already has it configured return } const skipDotDirs = (dir: string) => { const name = dir.split('/').pop() || '' return name.startsWith('.') } tsConfigPathsPlugin = tsconfigPaths({ skip: skipDotDirs, ...(pathsConfig && typeof pathsConfig === 'object' ? pathsConfig : {}), }) }, configResolved() {}, resolveId() {}, }, { get(target, key, thisArg) { if (key === 'config' || key === 'name') { return Reflect.get(target, key, thisArg) } if (tsConfigPathsPlugin) { return Reflect.get(tsConfigPathsPlugin, key, thisArg) } }, } ), { name: 'one-aliases', enforce: 'pre', config() { // const forkPath = dirname(resolvePath('one')) let tslibLitePath = '' try { // temp fix for seeing // Could not read from file: modules/@vxrn/resolve/dist/esm/@vxrn/tslib-lite tslibLitePath = resolvePath('@vxrn/tslib-lite', process.cwd()) } catch (err) { console.info(`Can't find tslib-lite, falling back to tslib`) if (process.env.DEBUG) { console.error(err) } } return { resolve: { alias: { // testing getting transition between routes working // 'use-sync-external-store/with-selector': resolvePath( // 'use-sync-external-store/shim/with-selector' // ), ...(tslibLitePath && { tslib: tslibLitePath, }), }, // [ // { // find: /tslib/, // replacement: resolvePath('@vxrn/tslib-lite'), // }, // // not working but would save ~30Kb stat // // { // // find: /@react-navigation\/core.*\/getStateFromPath/, // // replacement: join(forkPath, 'fork', 'getStateFromPath.mjs'), // // }, // // { // // find: /@react-navigation\/core.*\/getPathFromState/, // // replacement: join(forkPath, 'fork', 'getPathFromState.mjs'), // // }, // ], }, } }, }, { name: 'one:init-config', config() { return { define: { // we define this not in environment.client because there must be a bug in vite // it doesnt define the import.meta.env at all if you do that 'process.env.TAMAGUI_ENVIRONMENT': '"client"', 'process.env.VITE_ENVIRONMENT': '"client"', 'import.meta.env.VITE_ENVIRONMENT': '"client"', 'process.env.VITE_PLATFORM': '"web"', 'import.meta.env.VITE_PLATFORM': '"web"', 'process.env.EXPO_OS': '"web"', 'import.meta.env.EXPO_OS': '"web"', ...(options.web?.defaultRenderMode && { 'process.env.ONE_DEFAULT_RENDER_MODE': JSON.stringify( options.web.defaultRenderMode ), 'import.meta.env.ONE_DEFAULT_RENDER_MODE': JSON.stringify( options.web.defaultRenderMode ), }), ...(() => { if (!options.setupFile) return {} // normalize setupFile to object format let setupFiles: { client?: string server?: string ios?: string android?: string } if (typeof options.setupFile === 'string') { setupFiles = { client: options.setupFile, server: options.setupFile, ios: options.setupFile, android: options.setupFile, } } else if ('native' in options.setupFile) { setupFiles = { client: options.setupFile.client, server: options.setupFile.server, ios: options.setupFile.native, android: options.setupFile.native, } } else { setupFiles = options.setupFile } return { ...(setupFiles.client && { 'process.env.ONE_SETUP_FILE_CLIENT': JSON.stringify(setupFiles.client), }), ...(setupFiles.server && { 'process.env.ONE_SETUP_FILE_SERVER': JSON.stringify(setupFiles.server), }), ...(setupFiles.ios && { 'process.env.ONE_SETUP_FILE_IOS': JSON.stringify(setupFiles.ios), }), ...(setupFiles.android && { 'process.env.ONE_SETUP_FILE_ANDROID': JSON.stringify( setupFiles.android ), }), } })(), ...(process.env.NODE_ENV !== 'production' && vxrnOptions && { 'process.env.ONE_SERVER_URL': JSON.stringify(vxrnOptions.server.url), 'import.meta.env.ONE_SERVER_URL': JSON.stringify(vxrnOptions.server.url), }), }, environments: { // we define client vars not in environment.client because there must be a bug in vite // it doesnt define the import.meta.env at all if you do that // client: { // define: { // }, // }, ssr: { define: { 'process.env.TAMAGUI_ENVIRONMENT': '"ssr"', 'process.env.VITE_ENVIRONMENT': '"ssr"', // Note that we are also setting `process.env.VITE_ENVIRONMENT = 'ssr'` for this current process. See `setServerGlobals()` and `setupServerGlobals.ts`. 'import.meta.env.VITE_ENVIRONMENT': '"ssr"', 'process.env.VITE_PLATFORM': '"web"', 'import.meta.env.VITE_PLATFORM': '"web"', 'process.env.EXPO_OS': '"web"', 'import.meta.env.EXPO_OS': '"web"', }, }, ios: { define: { 'process.env.TAMAGUI_ENVIRONMENT': '"ios"', 'process.env.VITE_ENVIRONMENT': '"ios"', 'import.meta.env.VITE_ENVIRONMENT': '"ios"', 'process.env.VITE_PLATFORM': '"native"', 'import.meta.env.VITE_PLATFORM': '"native"', 'process.env.EXPO_OS': '"ios"', 'import.meta.env.EXPO_OS': '"ios"', }, }, android: { define: { 'process.env.TAMAGUI_ENVIRONMENT': '"android"', 'process.env.VITE_ENVIRONMENT': '"android"', 'import.meta.env.VITE_ENVIRONMENT': '"android"', 'process.env.VITE_PLATFORM': '"native"', 'import.meta.env.VITE_PLATFORM': '"native"', 'process.env.EXPO_OS': '"android"', 'import.meta.env.EXPO_OS': '"android"', }, }, }, } }, } satisfies Plugin, { name: 'one:tamagui', config() { return { define: { // safe to set because it only affects web in tamagui, and one is always react 19 'process.env.TAMAGUI_REACT_19': '"1"', }, environments: { ssr: { define: { 'process.env.TAMAGUI_IS_SERVER': '"1"', 'process.env.TAMAGUI_KEEP_THEMES': '"1"', }, }, ios: { define: { 'process.env.TAMAGUI_KEEP_THEMES': '"1"', }, }, android: { define: { 'process.env.TAMAGUI_KEEP_THEMES': '"1"', }, }, }, } }, } satisfies Plugin, { name: 'route-module-hmr-fix', hotUpdate({ server, modules, file }) { const envName = this.environment?.name // Check if this is an app file const fileRelativePath = path.relative(server.config.root, file) const fileRootDir = fileRelativePath.split(path.sep)[0] const isAppFile = fileRootDir === 'app' // For SSR environment, prevent full page reload for app files by returning empty array // The SSR module runner will still pick up changes on next request if (envName === 'ssr' && isAppFile) { return [] } let hasRouteUpdate = false const result = modules.map((m) => { const { id } = m if (!id) return m const relativePath = path.relative(server.config.root, id) // Get the root dir from relativePath const rootDir = relativePath.split(path.sep)[0] if (rootDir === 'app') { // If the file is a route, Vite might force a full-reload due to that file not being imported by any other modules (`!node.importers.size`) (see https://github.com/vitejs/vite/blob/v6.0.0-alpha.18/packages/vite/src/node/server/hmr.ts#L440-L443, https://github.com/vitejs/vite/blob/v6.0.0-alpha.18/packages/vite/src/node/server/hmr.ts#L427 and https://github.com/vitejs/vite/blob/v6.0.0-alpha.18/packages/vite/src/node/server/hmr.ts#L557-L566) // Here we trick Vite to skip that check. m.acceptedHmrExports = new Set() // Check if this is a ROOT layout file - only root layouts need special handling // because they're called as functions (not rendered as JSX) to support HTML elements // Root layout patterns: app/_layout.tsx or app/(group)/_layout.tsx const isRootLayout = relativePath === path.join('app', '_layout.tsx') || /^app[\\/]\([^)]+\)[\\/]_layout\.tsx$/.test(relativePath) if (isRootLayout) { hasRouteUpdate = true } } return m }) // For root layout files, send a custom event to trigger re-render // Root layouts are called as functions (not JSX) to support HTML elements, bypassing React's HMR if (hasRouteUpdate) { server.hot.send({ type: 'custom', event: 'one:route-update', data: { file: fileRelativePath }, }) } return result }, } satisfies Plugin, // Plugins may transform the source code and add imports of `react/jsx-dev-runtime`, which won't be discovered by Vite's initial `scanImports` since the implementation is using ESbuild where such plugins are not executed. // Thus, if the project has a valid `react/jsx-dev-runtime` import, we tell Vite to optimize it, so Vite won't only discover it on the next page load and trigger a full reload. { name: 'one:optimize-dev-deps', config(_, env) { if (env.mode === 'development') { return { optimizeDeps: { include: ['react/jsx-dev-runtime', 'react/compiler-runtime'], }, } } }, } satisfies Plugin, { name: 'one:remove-server-from-client', enforce: 'pre', transform(code, id) { if (this.environment.name === 'client') { if (id.includes(`one-server-only`)) { return code.replace( `import { AsyncLocalStorage } from "node:async_hooks"`, `class AsyncLocalStorage {}` ) } } }, }, ] satisfies Plugin[] // leaving this as a good example of an option that loads a library conditionally // // react scan // const scan = options.react?.scan // const reactScanPlugin = { // name: `one:react-scan`, // config() { // return reactScanConfig // }, // } // devAndProdPlugins.push(reactScanPlugin) // // do it here because it gets called a few times // const reactScanConfig = ((): UserConfig => { // const stringify = (obj: Object) => JSON.stringify(JSON.stringify(obj)) // const configs = { // disabled: { // define: { // 'process.env.ONE_ENABLE_REACT_SCAN': '""', // }, // }, // enabled: { // define: { // 'process.env.ONE_ENABLE_REACT_SCAN': stringify({ // enabled: true, // animationSpeed: 'slow', // showToolbar: false, // }), // }, // }, // } satisfies Record<string, UserConfig> // const getConfigFor = (platform: 'ios' | 'android' | 'client'): UserConfig => { // if (process.env.NODE_ENV === 'production') { // return configs.disabled // } // if (!scan) { // return configs.disabled // } // if (scan === true) { // return configs.enabled // } // if (typeof scan === 'string') { // if (scan === 'native' && platform === 'client') { // return configs.disabled // } // if (scan === 'web' && platform !== 'client') { // return configs.disabled // } // return configs.enabled // } // const defaultConfig = scan.options || configs.enabled // const perPlatformConfig = // platform === 'ios' || platform === 'android' ? scan.native : scan.web // return { // define: { // 'process.env.ONE_ENABLE_REACT_SCAN': stringify({ // ...defaultConfig, // ...perPlatformConfig, // }), // }, // } // } // return { // environments: { // client: getConfigFor('client'), // ios: getConfigFor('ios'), // android: getConfigFor('android'), // }, // } // })() // TODO move to single config and through environments const nativeWebDevAndProdPlugsin: Plugin[] = [ clientTreeShakePlugin(), // // reactScanPlugin ] // TODO make this passed into vxrn through real API globalThis.__vxrnAddNativePlugins = nativeWebDevAndProdPlugsin globalThis.__vxrnAddWebPluginsProd = devAndProdPlugins const flags: One.Flags = { experimentalPreventLayoutRemounting: options.router?.experimental?.preventLayoutRemounting, } return [ ...vxrnPlugins, ...devAndProdPlugins, ...nativeWebDevAndProdPlugsin, /** * This is really the meat of one, where it handles requests: */ createFileSystemRouterPlugin(options), generateFileSystemRouteTypesPlugin(options), fixDependenciesPlugin(options.deps), createVirtualEntry({ ...options, flags, root: routerRoot, }), { name: 'one-define-environment', config() { return { define: { ...(options.native?.key && { 'process.env.ONE_APP_NAME': JSON.stringify(options.native.key), 'import.meta.env.ONE_APP_NAME': JSON.stringify(options.native.key), }), 'process.env.ONE_CACHE_KEY': JSON.stringify(CACHE_KEY), 'import.meta.env.ONE_CACHE_KEY': JSON.stringify(CACHE_KEY), }, } }, } satisfies Plugin, SSRCSSPlugin({ entries: [virtualEntryId], }), ] }