one 1.16.12 → 1.17.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 (157) hide show
  1. package/dist/cjs/babel-preset/index.cjs +154 -0
  2. package/dist/cjs/babel-preset/index.native.js +186 -0
  3. package/dist/cjs/babel-preset/index.native.js.map +1 -0
  4. package/dist/cjs/babel-preset/index.test.cjs +143 -0
  5. package/dist/cjs/babel-preset/index.test.native.js +165 -0
  6. package/dist/cjs/babel-preset/index.test.native.js.map +1 -0
  7. package/dist/cjs/babel-preset/integration.test.cjs +94 -0
  8. package/dist/cjs/babel-preset/integration.test.native.js +97 -0
  9. package/dist/cjs/babel-preset/integration.test.native.js.map +1 -0
  10. package/dist/cjs/cli/generateBundlerConfig.cjs +247 -0
  11. package/dist/cjs/cli/generateBundlerConfig.native.js +316 -0
  12. package/dist/cjs/cli/generateBundlerConfig.native.js.map +1 -0
  13. package/dist/cjs/cli/generateBundlerConfig.test.cjs +350 -0
  14. package/dist/cjs/cli/generateBundlerConfig.test.native.js +380 -0
  15. package/dist/cjs/cli/generateBundlerConfig.test.native.js.map +1 -0
  16. package/dist/cjs/cli/patch.cjs +19 -2
  17. package/dist/cjs/cli/patch.native.js +26 -2
  18. package/dist/cjs/cli/patch.native.js.map +1 -1
  19. package/dist/cjs/cli/patch.test.cjs +56 -0
  20. package/dist/cjs/cli/patch.test.native.js +65 -0
  21. package/dist/cjs/cli/patch.test.native.js.map +1 -0
  22. package/dist/cjs/cli.cjs +39 -2
  23. package/dist/cjs/cli.native.js +40 -2
  24. package/dist/cjs/cli.native.js.map +1 -1
  25. package/dist/cjs/metro-config/buildOneMetroResolverOverrides.cjs +102 -0
  26. package/dist/cjs/metro-config/buildOneMetroResolverOverrides.native.js +109 -0
  27. package/dist/cjs/metro-config/buildOneMetroResolverOverrides.native.js.map +1 -0
  28. package/dist/cjs/metro-config/getViteMetroPluginOptions.cjs +17 -150
  29. package/dist/cjs/metro-config/getViteMetroPluginOptions.integration.test.cjs +84 -0
  30. package/dist/cjs/metro-config/getViteMetroPluginOptions.integration.test.native.js +90 -0
  31. package/dist/cjs/metro-config/getViteMetroPluginOptions.integration.test.native.js.map +1 -0
  32. package/dist/cjs/metro-config/getViteMetroPluginOptions.native.js +17 -158
  33. package/dist/cjs/metro-config/getViteMetroPluginOptions.native.js.map +1 -1
  34. package/dist/cjs/metro-config/withOne.cjs +82 -0
  35. package/dist/cjs/metro-config/withOne.native.js +88 -0
  36. package/dist/cjs/metro-config/withOne.native.js.map +1 -0
  37. package/dist/cjs/metro-config/withOne.test.cjs +129 -0
  38. package/dist/cjs/metro-config/withOne.test.native.js +156 -0
  39. package/dist/cjs/metro-config/withOne.test.native.js.map +1 -0
  40. package/dist/cjs/vite/loadConfig.cjs +20 -1
  41. package/dist/cjs/vite/loadConfig.native.js +20 -1
  42. package/dist/cjs/vite/loadConfig.native.js.map +1 -1
  43. package/dist/cjs/vite/plugins/warmRoutesPlugin.cjs +13 -7
  44. package/dist/cjs/vite/plugins/warmRoutesPlugin.native.js +13 -7
  45. package/dist/cjs/vite/plugins/warmRoutesPlugin.native.js.map +1 -1
  46. package/dist/esm/babel-preset/index.mjs +116 -0
  47. package/dist/esm/babel-preset/index.mjs.map +1 -0
  48. package/dist/esm/babel-preset/index.native.js +145 -0
  49. package/dist/esm/babel-preset/index.native.js.map +1 -0
  50. package/dist/esm/babel-preset/index.test.mjs +120 -0
  51. package/dist/esm/babel-preset/index.test.mjs.map +1 -0
  52. package/dist/esm/babel-preset/index.test.native.js +139 -0
  53. package/dist/esm/babel-preset/index.test.native.js.map +1 -0
  54. package/dist/esm/babel-preset/integration.test.mjs +71 -0
  55. package/dist/esm/babel-preset/integration.test.mjs.map +1 -0
  56. package/dist/esm/babel-preset/integration.test.native.js +71 -0
  57. package/dist/esm/babel-preset/integration.test.native.js.map +1 -0
  58. package/dist/esm/cli/generateBundlerConfig.mjs +207 -0
  59. package/dist/esm/cli/generateBundlerConfig.mjs.map +1 -0
  60. package/dist/esm/cli/generateBundlerConfig.native.js +273 -0
  61. package/dist/esm/cli/generateBundlerConfig.native.js.map +1 -0
  62. package/dist/esm/cli/generateBundlerConfig.test.mjs +327 -0
  63. package/dist/esm/cli/generateBundlerConfig.test.mjs.map +1 -0
  64. package/dist/esm/cli/generateBundlerConfig.test.native.js +354 -0
  65. package/dist/esm/cli/generateBundlerConfig.test.native.js.map +1 -0
  66. package/dist/esm/cli/patch.mjs +19 -2
  67. package/dist/esm/cli/patch.mjs.map +1 -1
  68. package/dist/esm/cli/patch.native.js +26 -2
  69. package/dist/esm/cli/patch.native.js.map +1 -1
  70. package/dist/esm/cli/patch.test.mjs +57 -0
  71. package/dist/esm/cli/patch.test.mjs.map +1 -0
  72. package/dist/esm/cli/patch.test.native.js +63 -0
  73. package/dist/esm/cli/patch.test.native.js.map +1 -0
  74. package/dist/esm/cli.mjs +39 -2
  75. package/dist/esm/cli.mjs.map +1 -1
  76. package/dist/esm/cli.native.js +40 -2
  77. package/dist/esm/cli.native.js.map +1 -1
  78. package/dist/esm/metro-config/buildOneMetroResolverOverrides.mjs +66 -0
  79. package/dist/esm/metro-config/buildOneMetroResolverOverrides.mjs.map +1 -0
  80. package/dist/esm/metro-config/buildOneMetroResolverOverrides.native.js +70 -0
  81. package/dist/esm/metro-config/buildOneMetroResolverOverrides.native.js.map +1 -0
  82. package/dist/esm/metro-config/getViteMetroPluginOptions.integration.test.mjs +61 -0
  83. package/dist/esm/metro-config/getViteMetroPluginOptions.integration.test.mjs.map +1 -0
  84. package/dist/esm/metro-config/getViteMetroPluginOptions.integration.test.native.js +64 -0
  85. package/dist/esm/metro-config/getViteMetroPluginOptions.integration.test.native.js.map +1 -0
  86. package/dist/esm/metro-config/getViteMetroPluginOptions.mjs +17 -138
  87. package/dist/esm/metro-config/getViteMetroPluginOptions.mjs.map +1 -1
  88. package/dist/esm/metro-config/getViteMetroPluginOptions.native.js +17 -146
  89. package/dist/esm/metro-config/getViteMetroPluginOptions.native.js.map +1 -1
  90. package/dist/esm/metro-config/withOne.mjs +45 -0
  91. package/dist/esm/metro-config/withOne.mjs.map +1 -0
  92. package/dist/esm/metro-config/withOne.native.js +48 -0
  93. package/dist/esm/metro-config/withOne.native.js.map +1 -0
  94. package/dist/esm/metro-config/withOne.test.mjs +106 -0
  95. package/dist/esm/metro-config/withOne.test.mjs.map +1 -0
  96. package/dist/esm/metro-config/withOne.test.native.js +130 -0
  97. package/dist/esm/metro-config/withOne.test.native.js.map +1 -0
  98. package/dist/esm/vite/loadConfig.mjs +20 -1
  99. package/dist/esm/vite/loadConfig.mjs.map +1 -1
  100. package/dist/esm/vite/loadConfig.native.js +20 -1
  101. package/dist/esm/vite/loadConfig.native.js.map +1 -1
  102. package/dist/esm/vite/plugins/warmRoutesPlugin.mjs +13 -7
  103. package/dist/esm/vite/plugins/warmRoutesPlugin.mjs.map +1 -1
  104. package/dist/esm/vite/plugins/warmRoutesPlugin.native.js +13 -7
  105. package/dist/esm/vite/plugins/warmRoutesPlugin.native.js.map +1 -1
  106. package/package.json +20 -9
  107. package/src/babel-preset/index.test.ts +148 -0
  108. package/src/babel-preset/index.ts +250 -0
  109. package/src/babel-preset/integration.test.ts +91 -0
  110. package/src/cli/generateBundlerConfig.test.ts +343 -0
  111. package/src/cli/generateBundlerConfig.ts +339 -0
  112. package/src/cli/patch.test.ts +65 -0
  113. package/src/cli/patch.ts +30 -2
  114. package/src/cli.ts +31 -0
  115. package/src/metro-config/buildOneMetroResolverOverrides.ts +104 -0
  116. package/src/metro-config/getViteMetroPluginOptions.integration.test.ts +75 -0
  117. package/src/metro-config/getViteMetroPluginOptions.ts +15 -230
  118. package/src/metro-config/withOne.test.ts +120 -0
  119. package/src/metro-config/withOne.ts +112 -0
  120. package/src/vite/loadConfig.ts +22 -0
  121. package/src/vite/plugins/warmRoutesPlugin.ts +22 -6
  122. package/types/babel-preset/index.d.ts +68 -0
  123. package/types/babel-preset/index.d.ts.map +1 -0
  124. package/types/babel-preset/index.test.d.ts +2 -0
  125. package/types/babel-preset/index.test.d.ts.map +1 -0
  126. package/types/babel-preset/integration.test.d.ts +2 -0
  127. package/types/babel-preset/integration.test.d.ts.map +1 -0
  128. package/types/cli/generateBundlerConfig.d.ts +61 -0
  129. package/types/cli/generateBundlerConfig.d.ts.map +1 -0
  130. package/types/cli/generateBundlerConfig.test.d.ts +2 -0
  131. package/types/cli/generateBundlerConfig.test.d.ts.map +1 -0
  132. package/types/cli/patch.d.ts.map +1 -1
  133. package/types/cli/patch.test.d.ts +2 -0
  134. package/types/cli/patch.test.d.ts.map +1 -0
  135. package/types/metro-config/buildOneMetroResolverOverrides.d.ts +20 -0
  136. package/types/metro-config/buildOneMetroResolverOverrides.d.ts.map +1 -0
  137. package/types/metro-config/getViteMetroPluginOptions.d.ts +0 -5
  138. package/types/metro-config/getViteMetroPluginOptions.d.ts.map +1 -1
  139. package/types/metro-config/getViteMetroPluginOptions.integration.test.d.ts +2 -0
  140. package/types/metro-config/getViteMetroPluginOptions.integration.test.d.ts.map +1 -0
  141. package/types/metro-config/withOne.d.ts +44 -0
  142. package/types/metro-config/withOne.d.ts.map +1 -0
  143. package/types/metro-config/withOne.test.d.ts +2 -0
  144. package/types/metro-config/withOne.test.d.ts.map +1 -0
  145. package/types/vite/loadConfig.d.ts +1 -0
  146. package/types/vite/loadConfig.d.ts.map +1 -1
  147. package/types/vite/plugins/warmRoutesPlugin.d.ts.map +1 -1
  148. package/dist/cjs/metro-config/getViteMetroPluginOptions.test.cjs +0 -23
  149. package/dist/cjs/metro-config/getViteMetroPluginOptions.test.native.js +0 -26
  150. package/dist/cjs/metro-config/getViteMetroPluginOptions.test.native.js.map +0 -1
  151. package/dist/esm/metro-config/getViteMetroPluginOptions.test.mjs +0 -24
  152. package/dist/esm/metro-config/getViteMetroPluginOptions.test.mjs.map +0 -1
  153. package/dist/esm/metro-config/getViteMetroPluginOptions.test.native.js +0 -24
  154. package/dist/esm/metro-config/getViteMetroPluginOptions.test.native.js.map +0 -1
  155. package/src/metro-config/getViteMetroPluginOptions.test.ts +0 -34
  156. package/types/metro-config/getViteMetroPluginOptions.test.d.ts +0 -2
  157. package/types/metro-config/getViteMetroPluginOptions.test.d.ts.map +0 -1
@@ -0,0 +1,339 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import nodeModule from 'node:module'
3
+ import path from 'node:path'
4
+ import colors from 'picocolors'
5
+ import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions'
6
+ import type { One } from '../vite/types'
7
+
8
+ /**
9
+ * Marker that identifies a bundler config as One-generated. If the file
10
+ * still contains this marker we can safely regenerate it; if the user
11
+ * removed the marker we treat the file as customized and never overwrite.
12
+ */
13
+ export const ONE_GENERATED_MARKER = '@one/generated bundler-config'
14
+
15
+ export type OneBundlerConfigOptions = {
16
+ routerRoot?: string
17
+ ignoredRouteFiles?: Array<`**/*${string}`>
18
+ linking?: NonNullable<One.PluginOptions['router']>['linking']
19
+ setupFile?: One.PluginOptions['setupFile']
20
+ }
21
+
22
+ function buildBabelConfigContent({
23
+ eject,
24
+ options,
25
+ }: {
26
+ eject: boolean
27
+ options: OneBundlerConfigOptions
28
+ }) {
29
+ const header = eject
30
+ ? `// you own this file. edit freely — \`one\` will not regenerate it.
31
+ // delegates to one/babel-preset which holds the canonical plugin chain.
32
+ `
33
+ : `// ${ONE_GENERATED_MARKER}
34
+ //
35
+ // auto-generated by \`one patch\` on ci/eas workers when expo-updates is
36
+ // in deps. delegates to one/babel-preset so expo export / eas update
37
+ // use the same router/setup options as \`one dev\` and \`one build\`.
38
+ //
39
+ // to customize, delete this header and edit freely — re-runs will then
40
+ // leave this file alone.
41
+ `
42
+
43
+ return `${header}
44
+ const oneBabelPreset = require('one/babel-preset')
45
+ const preset = oneBabelPreset.default || oneBabelPreset
46
+ const oneBundlerOptions = ${serializeBundlerConfigOptions(options)}
47
+
48
+ module.exports = function (api) {
49
+ return preset(api, oneBundlerOptions)
50
+ }
51
+ `
52
+ }
53
+
54
+ function buildMetroConfigContent({
55
+ eject,
56
+ options,
57
+ }: {
58
+ eject: boolean
59
+ options: OneBundlerConfigOptions
60
+ }) {
61
+ const header = eject
62
+ ? `// you own this file. edit freely — \`one\` will not regenerate it.
63
+ // withOne() invokes the same Metro pipeline One uses for production bundles.
64
+ `
65
+ : `// ${ONE_GENERATED_MARKER}
66
+ //
67
+ // auto-generated by \`one patch\` on ci/eas workers when expo-updates is
68
+ // in deps. delegates to one/metro-config which invokes the exact same
69
+ // metro pipeline one uses for production native bundles with your
70
+ // router/setup options — no separate expo/metro-config setup needed.
71
+ //
72
+ // to customize, delete this header and edit freely — re-runs will then
73
+ // leave this file alone.
74
+ `
75
+
76
+ return `${header}
77
+ const { withOne } = require('one/metro-config')
78
+ const oneBundlerOptions = ${serializeBundlerConfigOptions(options)}
79
+
80
+ module.exports = withOne(__dirname, oneBundlerOptions)
81
+ `
82
+ }
83
+
84
+ type FileSpec = {
85
+ name: string
86
+ getContent: (args: {
87
+ eject: boolean
88
+ options: OneBundlerConfigOptions
89
+ }) => string
90
+ conflicting: readonly string[]
91
+ }
92
+
93
+ const FILES: readonly FileSpec[] = [
94
+ {
95
+ name: 'babel.config.cjs',
96
+ getContent: buildBabelConfigContent,
97
+ conflicting: ['babel.config.js', 'babel.config.mjs', '.babelrc', '.babelrc.js'],
98
+ },
99
+ {
100
+ name: 'metro.config.cjs',
101
+ getContent: buildMetroConfigContent,
102
+ conflicting: ['metro.config.js', 'metro.config.mjs'],
103
+ },
104
+ ] as const
105
+
106
+ function stripUndefined(value: unknown): unknown {
107
+ if (Array.isArray(value)) {
108
+ return value.map(stripUndefined)
109
+ }
110
+
111
+ if (value && typeof value === 'object') {
112
+ return Object.fromEntries(
113
+ Object.entries(value)
114
+ .filter(([, entry]) => entry !== undefined)
115
+ .map(([key, entry]) => [key, stripUndefined(entry)])
116
+ )
117
+ }
118
+
119
+ return value
120
+ }
121
+
122
+ function assertSerializable(value: unknown, keyPath = 'one bundler options') {
123
+ if (
124
+ typeof value === 'function' ||
125
+ typeof value === 'symbol' ||
126
+ typeof value === 'bigint'
127
+ ) {
128
+ throw new Error(
129
+ `[one] ${keyPath} must be JSON-serializable to generate Babel/Metro config files. Move function-valued native linking/customization into an ejected config.`
130
+ )
131
+ }
132
+
133
+ if (Array.isArray(value)) {
134
+ value.forEach((entry, index) => assertSerializable(entry, `${keyPath}[${index}]`))
135
+ return
136
+ }
137
+
138
+ if (value && typeof value === 'object') {
139
+ for (const [key, entry] of Object.entries(value)) {
140
+ assertSerializable(entry, `${keyPath}.${key}`)
141
+ }
142
+ }
143
+ }
144
+
145
+ function serializeBundlerConfigOptions(options: OneBundlerConfigOptions): string {
146
+ const clean = stripUndefined(options) as OneBundlerConfigOptions
147
+ assertSerializable(clean)
148
+ return JSON.stringify(clean, null, 2)
149
+ }
150
+
151
+ export function getBundlerConfigOptionsFromOneOptions(
152
+ oneOptions: One.PluginOptions = {}
153
+ ): OneBundlerConfigOptions {
154
+ return stripUndefined({
155
+ routerRoot: getRouterRootFromOneOptions(oneOptions),
156
+ ignoredRouteFiles: oneOptions.router?.ignoredRouteFiles,
157
+ linking: oneOptions.router?.linking,
158
+ setupFile: oneOptions.setupFile,
159
+ }) as OneBundlerConfigOptions
160
+ }
161
+
162
+ export type GenerateBundlerConfigArgs = {
163
+ /** Project root. Defaults to `process.cwd()`. */
164
+ cwd?: string
165
+ /** loaded one plugin options from vite.config. */
166
+ oneOptions?: One.PluginOptions
167
+ /** Overwrite even when the file has been customized (marker removed). */
168
+ force?: boolean
169
+ /** Just verify state without writing — exits non-zero when out of sync. */
170
+ check?: boolean
171
+ /** Suppress logging. */
172
+ quiet?: boolean
173
+ /**
174
+ * Write files WITHOUT the `@one/generated` marker. The user owns the file
175
+ * after this; subsequent CI auto-gen runs will treat it as customized and
176
+ * skip it. used by `one metro-eject`.
177
+ */
178
+ eject?: boolean
179
+ }
180
+
181
+ export type FileResult = {
182
+ filePath: string
183
+ action: 'wrote' | 'kept' | 'skipped-customized' | 'skipped-other-format' | 'would-write' | 'would-overwrite'
184
+ reason?: string
185
+ }
186
+
187
+ export function generateBundlerConfig(args: GenerateBundlerConfigArgs = {}): {
188
+ results: FileResult[]
189
+ ok: boolean
190
+ } {
191
+ const cwd = path.resolve(args.cwd ?? process.cwd())
192
+ const force = !!args.force
193
+ const check = !!args.check
194
+ const quiet = !!args.quiet
195
+
196
+ const log = (msg: string) => {
197
+ if (!quiet) console.info(msg)
198
+ }
199
+ const warn = (msg: string) => {
200
+ if (!quiet) console.warn(msg)
201
+ }
202
+
203
+ const results: FileResult[] = []
204
+
205
+ const eject = !!args.eject
206
+ const bundlerOptions = getBundlerConfigOptionsFromOneOptions(args.oneOptions)
207
+
208
+ for (const file of FILES) {
209
+ const filePath = path.join(cwd, file.name)
210
+ const targetContent = file.getContent({ eject, options: bundlerOptions })
211
+
212
+ // detect conflicting other-extension variants the user might be using
213
+ const conflict = file.conflicting.find((alt) => existsSync(path.join(cwd, alt)))
214
+ if (conflict && !existsSync(filePath)) {
215
+ results.push({
216
+ filePath: path.join(cwd, conflict),
217
+ action: 'skipped-other-format',
218
+ reason: `Found ${conflict}; not creating ${file.name}. To switch, delete ${conflict} and re-run with --force.`,
219
+ })
220
+ warn(
221
+ colors.yellow(
222
+ `[one] found ${conflict} — leaving it alone. Delete it and re-run with --force to switch to ${file.name}.`
223
+ )
224
+ )
225
+ continue
226
+ }
227
+
228
+ if (!existsSync(filePath)) {
229
+ if (check) {
230
+ results.push({ filePath, action: 'would-write' })
231
+ log(colors.yellow(`[one] missing: ${file.name}`))
232
+ continue
233
+ }
234
+ writeFileSync(filePath, targetContent)
235
+ results.push({ filePath, action: 'wrote' })
236
+ log(colors.green(`[one] wrote ${file.name}`))
237
+ continue
238
+ }
239
+
240
+ const existing = readFileSync(filePath, 'utf8')
241
+
242
+ if (existing === targetContent) {
243
+ results.push({ filePath, action: 'kept' })
244
+ log(colors.dim(`[one] up to date: ${file.name}`))
245
+ continue
246
+ }
247
+
248
+ const hasMarker = existing.includes(ONE_GENERATED_MARKER)
249
+
250
+ if (!hasMarker && !force) {
251
+ results.push({
252
+ filePath,
253
+ action: 'skipped-customized',
254
+ reason: `${file.name} has been customized (no @one marker). Re-add the marker comment or pass --force to overwrite.`,
255
+ })
256
+ warn(
257
+ colors.yellow(
258
+ `[one] ${file.name} appears customized — skipping. Pass --force to overwrite.`
259
+ )
260
+ )
261
+ continue
262
+ }
263
+
264
+ if (check) {
265
+ results.push({ filePath, action: 'would-overwrite' })
266
+ log(colors.yellow(`[one] out of date: ${file.name}`))
267
+ continue
268
+ }
269
+
270
+ writeFileSync(filePath, targetContent)
271
+ results.push({ filePath, action: 'wrote' })
272
+ log(colors.green(`[one] updated ${file.name}`))
273
+ }
274
+
275
+ // "ok" means the on-disk state is something we can live with — either we
276
+ // wrote what we wanted, the existing file is up to date, or the user has
277
+ // explicitly customized (their intent, not our problem).
278
+ // check mode is stricter: missing/stale files mean a regen is needed.
279
+ const acceptableAlways = new Set<FileResult['action']>([
280
+ 'wrote',
281
+ 'kept',
282
+ 'skipped-other-format',
283
+ 'skipped-customized',
284
+ ])
285
+ const acceptableInCheck = new Set<FileResult['action']>([
286
+ 'kept',
287
+ 'skipped-other-format',
288
+ 'skipped-customized',
289
+ ])
290
+ const ok = (check ? acceptableInCheck : acceptableAlways).size
291
+ ? results.every((r) =>
292
+ (check ? acceptableInCheck : acceptableAlways).has(r.action)
293
+ )
294
+ : false
295
+
296
+ return { results, ok }
297
+ }
298
+
299
+ /**
300
+ * True when running on a CI/EAS worker. We only auto-generate bundler-config
301
+ * files in CI so they never appear in a developer's local working tree.
302
+ *
303
+ * Accepts any truthy value for `CI` / `EAS_BUILD` since providers vary:
304
+ * GitHub Actions sets `CI=true`, others use `CI=1`, EAS sets `EAS_BUILD=true`.
305
+ *
306
+ * Set `CI=1` (or `EAS_BUILD=true`) ahead of `eas update` if you need to
307
+ * publish from a local machine.
308
+ */
309
+ export function isCiEnvironment(): boolean {
310
+ const truthy = (v: string | undefined) =>
311
+ !!v && v !== 'false' && v !== '0'
312
+ return truthy(process.env.EAS_BUILD) || truthy(process.env.CI)
313
+ }
314
+
315
+ /**
316
+ * Postinstall hook: when expo-updates is in deps AND we're running on
317
+ * a CI/EAS worker, ensure the bundler-config files exist so the
318
+ * subsequent `expo export` / EXUpdates Metro pass succeeds.
319
+ *
320
+ * No-op locally so the files never show up in a developer's working tree.
321
+ */
322
+ export function maybeGenerateBundlerConfigOnInstall(
323
+ cwd: string = process.cwd(),
324
+ oneOptions?: One.PluginOptions
325
+ ): void {
326
+ if (!isCiEnvironment()) return
327
+
328
+ // detect expo-updates via the project's own resolver — same check used
329
+ // by the vxrn expo-plugin and one prebuild
330
+ try {
331
+ nodeModule
332
+ .createRequire(cwd + '/')
333
+ .resolve('expo-updates/package.json')
334
+ } catch {
335
+ return
336
+ }
337
+
338
+ generateBundlerConfig({ cwd, quiet: false, oneOptions })
339
+ }
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { run } from './patch'
3
+
4
+ const { patchMock, loadUserOneOptionsMock } = vi.hoisted(() => ({
5
+ patchMock: vi.fn(),
6
+ loadUserOneOptionsMock: vi.fn(),
7
+ }))
8
+
9
+ vi.mock('vxrn', () => ({
10
+ patch: patchMock,
11
+ }))
12
+
13
+ vi.mock('../vite/loadConfig', () => ({
14
+ loadUserOneOptions: loadUserOneOptionsMock,
15
+ }))
16
+
17
+ describe('one patch', () => {
18
+ beforeEach(() => {
19
+ patchMock.mockReset()
20
+ loadUserOneOptionsMock.mockReset()
21
+ })
22
+
23
+ it('applies built-in patches when a native-only app has no vite config', async () => {
24
+ loadUserOneOptionsMock.mockRejectedValueOnce(
25
+ new Error('No config config in /tmp/native-only-app. Is this the correct directory?')
26
+ )
27
+
28
+ await run({})
29
+
30
+ expect(patchMock).toHaveBeenCalledWith({
31
+ root: process.cwd(),
32
+ deps: undefined,
33
+ force: undefined,
34
+ })
35
+ })
36
+
37
+ it('passes configured user patches through to vxrn', async () => {
38
+ const patches = {
39
+ 'example-package': {
40
+ version: '1',
41
+ 'index.js': 'export default 1',
42
+ },
43
+ }
44
+ loadUserOneOptionsMock.mockResolvedValueOnce({
45
+ oneOptions: { patches },
46
+ })
47
+
48
+ await run({ force: true })
49
+
50
+ expect(patchMock).toHaveBeenCalledWith({
51
+ root: process.cwd(),
52
+ deps: patches,
53
+ force: true,
54
+ })
55
+ })
56
+
57
+ it('keeps failing when a vite config exists but does not load one', async () => {
58
+ loadUserOneOptionsMock.mockRejectedValueOnce(
59
+ new Error('One not loaded properly, is the one() plugin in your vite.config.ts?')
60
+ )
61
+
62
+ await expect(run({})).rejects.toThrow('One not loaded properly')
63
+ expect(patchMock).not.toHaveBeenCalled()
64
+ })
65
+ })
package/src/cli/patch.ts CHANGED
@@ -1,12 +1,40 @@
1
1
  import type { SimpleDepPatchObject, PatchOptions } from 'vxrn'
2
2
  import { loadUserOneOptions } from '../vite/loadConfig'
3
+ import { maybeGenerateBundlerConfigOnInstall } from './generateBundlerConfig'
4
+
5
+ function isMissingViteConfigError(error: unknown) {
6
+ return (
7
+ error instanceof Error &&
8
+ error.message.startsWith('No config config in ') &&
9
+ error.message.endsWith(' Is this the correct directory?')
10
+ )
11
+ }
12
+
13
+ async function loadUserOptions() {
14
+ try {
15
+ return await loadUserOneOptions('build')
16
+ } catch (error) {
17
+ if (isMissingViteConfigError(error)) {
18
+ return undefined
19
+ }
20
+ throw error
21
+ }
22
+ }
3
23
 
4
24
  export async function run(args: { force?: boolean }) {
5
25
  process.env.IS_VXRN_CLI = 'true'
6
26
  const { patch } = await import('vxrn')
7
27
 
8
- const options = await loadUserOneOptions('build')
9
- const patches = options.oneOptions.patches as SimpleDepPatchObject | undefined
28
+ const options = await loadUserOptions()
29
+
30
+ // ensure babel.config.cjs + metro.config.cjs exist when a project uses
31
+ // expo-updates and we're on an eas/ci worker. the generated files capture
32
+ // the loaded one() router/setup options so standalone metro matches one.
33
+ if (options) {
34
+ maybeGenerateBundlerConfigOnInstall(process.cwd(), options.oneOptions)
35
+ }
36
+
37
+ const patches = options?.oneOptions.patches as SimpleDepPatchObject | undefined
10
38
 
11
39
  if (process.env.DEBUG) {
12
40
  console.info('User patches:', Object.keys(patches || {}))
package/src/cli.ts CHANGED
@@ -33,6 +33,7 @@ const docsLinks = {
33
33
  patch: `${DOCS_BASE}/configuration`,
34
34
  'generate-routes': `${DOCS_BASE}/routing-typed-routes`,
35
35
  typegen: `${DOCS_BASE}/routing-typed-routes`,
36
+ 'metro-eject': `${DOCS_BASE}/guides-ota-updates`,
36
37
  } as const
37
38
 
38
39
  function withDocsLink(description: string, command: keyof typeof docsLinks): string {
@@ -353,6 +354,35 @@ const typegen = defineCommand({
353
354
  },
354
355
  })
355
356
 
357
+ const metroEject = defineCommand({
358
+ meta: {
359
+ name: 'metro-eject',
360
+ version: version,
361
+ description: withDocsLink(
362
+ 'Write babel.config.cjs + metro.config.cjs so you can own them and customize freely',
363
+ 'metro-eject'
364
+ ),
365
+ },
366
+ args: {
367
+ force: {
368
+ type: 'boolean',
369
+ description: 'Overwrite existing files',
370
+ },
371
+ },
372
+ async run({ args }) {
373
+ const { generateBundlerConfig } = await import('./cli/generateBundlerConfig')
374
+ const { loadUserOneOptions } = await import('./vite/loadConfig')
375
+ process.env.IS_VXRN_CLI = 'true'
376
+ const { oneOptions } = await loadUserOneOptions('build')
377
+ const { ok } = generateBundlerConfig({
378
+ force: !!args.force,
379
+ eject: true,
380
+ oneOptions,
381
+ })
382
+ if (!ok) process.exit(1)
383
+ },
384
+ })
385
+
356
386
  const daemonCommand = defineCommand({
357
387
  meta: {
358
388
  name: 'daemon',
@@ -412,6 +442,7 @@ const subCommands = {
412
442
  'generate-routes': generateRoutes,
413
443
  typegen,
414
444
  daemon: daemonCommand,
445
+ 'metro-eject': metroEject,
415
446
  }
416
447
 
417
448
  // workaround for having sub-commands but also positional arg for naming in the create flow
@@ -0,0 +1,104 @@
1
+ import module from 'node:module'
2
+ import path from 'node:path'
3
+
4
+ export type BuildOneMetroResolverOverridesOptions = {
5
+ projectRoot: string
6
+ }
7
+
8
+ export type MetroConfigLike = { resolver?: Record<string, any> } | undefined
9
+
10
+ /**
11
+ * Build the Metro resolver overrides One needs for native bundles.
12
+ *
13
+ * Used by getViteMetroPluginOptions, which feeds these into the same
14
+ * getMetroConfigFromViteConfig pipeline both production native bundles and
15
+ * standalone Metro invocations (expo export, eas update) go through. The
16
+ * overrides handle One-specific concerns: server-only stripping, .css → empty,
17
+ * _middleware → empty, react-native-svg fix.
18
+ *
19
+ * Returns a function that takes Metro's default config and produces an
20
+ * overridden config. Callers compose any additional overrides on top.
21
+ */
22
+ export function buildOneMetroResolverOverrides({
23
+ projectRoot,
24
+ }: BuildOneMetroResolverOverridesOptions): <T extends MetroConfigLike>(
25
+ defaultConfig: T
26
+ ) => T {
27
+ const require = module.createRequire(projectRoot + '/')
28
+ const emptyPath = require.resolve('@vxrn/vite-plugin-metro/empty', {
29
+ paths: [projectRoot],
30
+ })
31
+
32
+ return <T extends MetroConfigLike>(defaultConfig: T): T => {
33
+ const resolver: Record<string, any> = {
34
+ ...defaultConfig?.resolver,
35
+ extraNodeModules: {
36
+ ...defaultConfig?.resolver?.extraNodeModules,
37
+ },
38
+ nodeModulesPaths: defaultConfig?.resolver?.nodeModulesPaths,
39
+ resolveRequest: (context: any, moduleName: string, platform: string) => {
40
+ if (moduleName.endsWith('.css')) {
41
+ return {
42
+ type: 'sourceFile',
43
+ filePath: emptyPath,
44
+ }
45
+ }
46
+
47
+ // On Vite side this is done by excludeAPIAndMiddlewareRoutesPlugin
48
+ if (/_middleware.tsx?$/.test(moduleName)) {
49
+ return {
50
+ type: 'sourceFile',
51
+ filePath: emptyPath,
52
+ }
53
+ }
54
+
55
+ // server-only files should never be in the native bundle.
56
+ // metro follows dynamic import chains (e.g. zero models →
57
+ // server effects → server packages) and tries to resolve
58
+ // everything, even though the code only runs on the server.
59
+ if (/\.server(\.[jt]sx?)?$/.test(moduleName)) {
60
+ return {
61
+ type: 'sourceFile',
62
+ filePath: emptyPath,
63
+ }
64
+ }
65
+
66
+ // react-native-svg's package.json has "react-native": "src/index.ts"
67
+ // which points to TS source that only type-exports Svg/Circle/Path etc.
68
+ // force resolution to the compiled JS which has proper named value exports.
69
+ if (moduleName === 'react-native-svg') {
70
+ const defaultResolveRequest =
71
+ defaultConfig?.resolver?.resolveRequest || context.resolveRequest
72
+ const res = defaultResolveRequest(context, moduleName, platform)
73
+ const svgSrcSuffix = `${path.sep}src${path.sep}index.ts`
74
+ if (res && 'filePath' in res && res.filePath.includes(svgSrcSuffix)) {
75
+ return {
76
+ ...res,
77
+ filePath: res.filePath.replace(
78
+ svgSrcSuffix,
79
+ `${path.sep}lib${path.sep}commonjs${path.sep}index.js`
80
+ ),
81
+ }
82
+ }
83
+ return res
84
+ }
85
+
86
+ const defaultResolveRequest =
87
+ defaultConfig?.resolver?.resolveRequest || context.resolveRequest
88
+ const res = defaultResolveRequest(context, moduleName, platform)
89
+
90
+ // catch .server files that were resolved by path
91
+ if (res && 'filePath' in res && /\.server\.[jt]sx?$/.test(res.filePath)) {
92
+ return { type: 'sourceFile', filePath: emptyPath }
93
+ }
94
+
95
+ return res
96
+ },
97
+ }
98
+
99
+ return {
100
+ ...(defaultConfig as any),
101
+ resolver,
102
+ } as T
103
+ }
104
+ }
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
4
+ import { getViteMetroPluginOptions } from './getViteMetroPluginOptions'
5
+
6
+ /**
7
+ * the vite-driven metro path must always inject one's required babel plugins.
8
+ * a project babel.config can customize normal babel behavior, but it cannot be
9
+ * the source of truth for one's router/server-code transforms because ordinary
10
+ * react native configs do not know about them.
11
+ */
12
+ describe('getViteMetroPluginOptions babel-config coexistence', () => {
13
+ let tmpDir: string
14
+
15
+ beforeAll(() => {
16
+ // place the fixture under the workspace root so Node can walk up to
17
+ // node_modules and resolve `one/metro-entry` + `@vxrn/vite-plugin-metro/empty`
18
+ const workspaceRoot = path.resolve(__dirname, '../../../../')
19
+ tmpDir = fs.mkdtempSync(path.join(workspaceRoot, '.tmp-one-vite-metro-test-'))
20
+ fs.writeFileSync(
21
+ path.join(tmpDir, 'tsconfig.json'),
22
+ JSON.stringify({ compilerOptions: { paths: {} } })
23
+ )
24
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'tmp' }))
25
+ })
26
+
27
+ afterAll(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true })
29
+ })
30
+
31
+ it('injects plugins when there is no user babel.config', () => {
32
+ const opts = getViteMetroPluginOptions({
33
+ projectRoot: tmpDir,
34
+ relativeRouterRoot: 'app',
35
+ })
36
+
37
+ expect(opts?.babelConfig?.plugins).toBeDefined()
38
+ expect(opts?.oneViteMetroBabelConfig).toBe(true)
39
+ // 5 One plugins (Vite path skips import-meta-env-plugin since it's
40
+ // injected separately via getMetroBabelConfigFromViteConfig)
41
+ expect(opts?.babelConfig?.plugins?.length).toBe(5)
42
+ })
43
+
44
+ it('still injects One plugins when the user has a babel.config.cjs', () => {
45
+ const cfgPath = path.join(tmpDir, 'babel.config.cjs')
46
+ fs.writeFileSync(cfgPath, "module.exports = require('one/babel-preset')\n")
47
+
48
+ try {
49
+ const opts = getViteMetroPluginOptions({
50
+ projectRoot: tmpDir,
51
+ relativeRouterRoot: 'app',
52
+ })
53
+
54
+ expect(opts?.babelConfig?.plugins?.length).toBe(5)
55
+ } finally {
56
+ fs.unlinkSync(cfgPath)
57
+ }
58
+ })
59
+
60
+ it('still injects One plugins when the user has a babel.config.js', () => {
61
+ const cfgPath = path.join(tmpDir, 'babel.config.js')
62
+ fs.writeFileSync(cfgPath, "module.exports = require('one/babel-preset')\n")
63
+
64
+ try {
65
+ const opts = getViteMetroPluginOptions({
66
+ projectRoot: tmpDir,
67
+ relativeRouterRoot: 'app',
68
+ })
69
+
70
+ expect(opts?.babelConfig?.plugins?.length).toBe(5)
71
+ } finally {
72
+ fs.unlinkSync(cfgPath)
73
+ }
74
+ })
75
+ })