one
Version:
One is a new React Framework that makes Vite serve both native and web.
340 lines (300 loc) • 10.5 kB
text/typescript
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 })
}