UNPKG

one

Version:

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

340 lines (300 loc) 10.5 kB
import { existsSync, readFileSync, writeFileSync } from 'node:fs' import nodeModule from 'node:module' import path from 'node:path' import colors from 'picocolors' import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions' import type { One } from '../vite/types' /** * Marker that identifies a bundler config as One-generated. If the file * still contains this marker we can safely regenerate it; if the user * removed the marker we treat the file as customized and never overwrite. */ export const ONE_GENERATED_MARKER = '@one/generated bundler-config' export type OneBundlerConfigOptions = { routerRoot?: string ignoredRouteFiles?: Array<`**/*${string}`> linking?: NonNullable<One.PluginOptions['router']>['linking'] setupFile?: One.PluginOptions['setupFile'] } function buildBabelConfigContent({ eject, options, }: { eject: boolean options: OneBundlerConfigOptions }) { const header = eject ? `// you own this file. edit freely — \`one\` will not regenerate it. // delegates to one/babel-preset which holds the canonical plugin chain. ` : `// ${ONE_GENERATED_MARKER} // // auto-generated by \`one patch\` on ci/eas workers when expo-updates is // in deps. delegates to one/babel-preset so expo export / eas update // use the same router/setup options as \`one dev\` and \`one build\`. // // to customize, delete this header and edit freely — re-runs will then // leave this file alone. ` return `${header} const oneBabelPreset = require('one/babel-preset') const preset = oneBabelPreset.default || oneBabelPreset const oneBundlerOptions = ${serializeBundlerConfigOptions(options)} module.exports = function (api) { return preset(api, oneBundlerOptions) } ` } function buildMetroConfigContent({ eject, options, }: { eject: boolean options: OneBundlerConfigOptions }) { const header = eject ? `// you own this file. edit freely — \`one\` will not regenerate it. // withOne() invokes the same Metro pipeline One uses for production bundles. ` : `// ${ONE_GENERATED_MARKER} // // auto-generated by \`one patch\` on ci/eas workers when expo-updates is // in deps. delegates to one/metro-config which invokes the exact same // metro pipeline one uses for production native bundles with your // router/setup options — no separate expo/metro-config setup needed. // // to customize, delete this header and edit freely — re-runs will then // leave this file alone. ` return `${header} const { withOne } = require('one/metro-config') const oneBundlerOptions = ${serializeBundlerConfigOptions(options)} module.exports = withOne(__dirname, oneBundlerOptions) ` } type FileSpec = { name: string getContent: (args: { eject: boolean options: OneBundlerConfigOptions }) => string conflicting: readonly string[] } const FILES: readonly FileSpec[] = [ { name: 'babel.config.cjs', getContent: buildBabelConfigContent, conflicting: ['babel.config.js', 'babel.config.mjs', '.babelrc', '.babelrc.js'], }, { name: 'metro.config.cjs', getContent: buildMetroConfigContent, conflicting: ['metro.config.js', 'metro.config.mjs'], }, ] as const function stripUndefined(value: unknown): unknown { if (Array.isArray(value)) { return value.map(stripUndefined) } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value) .filter(([, entry]) => entry !== undefined) .map(([key, entry]) => [key, stripUndefined(entry)]) ) } return value } function assertSerializable(value: unknown, keyPath = 'one bundler options') { if ( typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint' ) { throw new Error( `[one] ${keyPath} must be JSON-serializable to generate Babel/Metro config files. Move function-valued native linking/customization into an ejected config.` ) } if (Array.isArray(value)) { value.forEach((entry, index) => assertSerializable(entry, `${keyPath}[${index}]`)) return } if (value && typeof value === 'object') { for (const [key, entry] of Object.entries(value)) { assertSerializable(entry, `${keyPath}.${key}`) } } } function serializeBundlerConfigOptions(options: OneBundlerConfigOptions): string { const clean = stripUndefined(options) as OneBundlerConfigOptions assertSerializable(clean) return JSON.stringify(clean, null, 2) } export function getBundlerConfigOptionsFromOneOptions( oneOptions: One.PluginOptions = {} ): OneBundlerConfigOptions { return stripUndefined({ routerRoot: getRouterRootFromOneOptions(oneOptions), ignoredRouteFiles: oneOptions.router?.ignoredRouteFiles, linking: oneOptions.router?.linking, setupFile: oneOptions.setupFile, }) as OneBundlerConfigOptions } export type GenerateBundlerConfigArgs = { /** Project root. Defaults to `process.cwd()`. */ cwd?: string /** loaded one plugin options from vite.config. */ oneOptions?: One.PluginOptions /** Overwrite even when the file has been customized (marker removed). */ force?: boolean /** Just verify state without writing — exits non-zero when out of sync. */ check?: boolean /** Suppress logging. */ quiet?: boolean /** * Write files WITHOUT the `@one/generated` marker. The user owns the file * after this; subsequent CI auto-gen runs will treat it as customized and * skip it. used by `one metro-eject`. */ eject?: boolean } export type FileResult = { filePath: string action: 'wrote' | 'kept' | 'skipped-customized' | 'skipped-other-format' | 'would-write' | 'would-overwrite' reason?: string } export function generateBundlerConfig(args: GenerateBundlerConfigArgs = {}): { results: FileResult[] ok: boolean } { const cwd = path.resolve(args.cwd ?? process.cwd()) const force = !!args.force const check = !!args.check const quiet = !!args.quiet const log = (msg: string) => { if (!quiet) console.info(msg) } const warn = (msg: string) => { if (!quiet) console.warn(msg) } const results: FileResult[] = [] const eject = !!args.eject const bundlerOptions = getBundlerConfigOptionsFromOneOptions(args.oneOptions) for (const file of FILES) { const filePath = path.join(cwd, file.name) const targetContent = file.getContent({ eject, options: bundlerOptions }) // detect conflicting other-extension variants the user might be using const conflict = file.conflicting.find((alt) => existsSync(path.join(cwd, alt))) if (conflict && !existsSync(filePath)) { results.push({ filePath: path.join(cwd, conflict), action: 'skipped-other-format', reason: `Found ${conflict}; not creating ${file.name}. To switch, delete ${conflict} and re-run with --force.`, }) warn( colors.yellow( `[one] found ${conflict} — leaving it alone. Delete it and re-run with --force to switch to ${file.name}.` ) ) continue } if (!existsSync(filePath)) { if (check) { results.push({ filePath, action: 'would-write' }) log(colors.yellow(`[one] missing: ${file.name}`)) continue } writeFileSync(filePath, targetContent) results.push({ filePath, action: 'wrote' }) log(colors.green(`[one] wrote ${file.name}`)) continue } const existing = readFileSync(filePath, 'utf8') if (existing === targetContent) { results.push({ filePath, action: 'kept' }) log(colors.dim(`[one] up to date: ${file.name}`)) continue } const hasMarker = existing.includes(ONE_GENERATED_MARKER) if (!hasMarker && !force) { results.push({ filePath, action: 'skipped-customized', reason: `${file.name} has been customized (no @one marker). Re-add the marker comment or pass --force to overwrite.`, }) warn( colors.yellow( `[one] ${file.name} appears customized — skipping. Pass --force to overwrite.` ) ) continue } if (check) { results.push({ filePath, action: 'would-overwrite' }) log(colors.yellow(`[one] out of date: ${file.name}`)) continue } writeFileSync(filePath, targetContent) results.push({ filePath, action: 'wrote' }) log(colors.green(`[one] updated ${file.name}`)) } // "ok" means the on-disk state is something we can live with — either we // wrote what we wanted, the existing file is up to date, or the user has // explicitly customized (their intent, not our problem). // check mode is stricter: missing/stale files mean a regen is needed. const acceptableAlways = new Set<FileResult['action']>([ 'wrote', 'kept', 'skipped-other-format', 'skipped-customized', ]) const acceptableInCheck = new Set<FileResult['action']>([ 'kept', 'skipped-other-format', 'skipped-customized', ]) const ok = (check ? acceptableInCheck : acceptableAlways).size ? results.every((r) => (check ? acceptableInCheck : acceptableAlways).has(r.action) ) : false return { results, ok } } /** * True when running on a CI/EAS worker. We only auto-generate bundler-config * files in CI so they never appear in a developer's local working tree. * * Accepts any truthy value for `CI` / `EAS_BUILD` since providers vary: * GitHub Actions sets `CI=true`, others use `CI=1`, EAS sets `EAS_BUILD=true`. * * Set `CI=1` (or `EAS_BUILD=true`) ahead of `eas update` if you need to * publish from a local machine. */ export function isCiEnvironment(): boolean { const truthy = (v: string | undefined) => !!v && v !== 'false' && v !== '0' return truthy(process.env.EAS_BUILD) || truthy(process.env.CI) } /** * Postinstall hook: when expo-updates is in deps AND we're running on * a CI/EAS worker, ensure the bundler-config files exist so the * subsequent `expo export` / EXUpdates Metro pass succeeds. * * No-op locally so the files never show up in a developer's working tree. */ export function maybeGenerateBundlerConfigOnInstall( cwd: string = process.cwd(), oneOptions?: One.PluginOptions ): void { if (!isCiEnvironment()) return // detect expo-updates via the project's own resolver — same check used // by the vxrn expo-plugin and one prebuild try { nodeModule .createRequire(cwd + '/') .resolve('expo-updates/package.json') } catch { return } generateBundlerConfig({ cwd, quiet: false, oneOptions }) }