Bundled @expo/config cannot read a TypeScript app.config.ts (two independent failures in eas-cli 22.0.0)
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 236
- Avg merge
- 3d 1h
- Merged PRs (30d)
- 91
Description
## Summary
The `@expo/config` bundled inside EAS CLI cannot read a TypeScript `app.config.ts`. There are two independent failures, both reproducible from a blank project on `eas-cli@22.0.0` and `21.8.0`:
1. A config that references `import.meta` fails with `exports is not defined in ES module scope`.
2. Any TypeScript config fails with `Cannot read properties of undefined (reading 'CommonJS')`.
Both come from EAS CLI shipping a config-reading stack several SDK generations behind the configs it reads. The bundled copy is only reached after a primary config read has already failed, so its own failure gets appended to an unrelated error. Users then see a second, misleading error blaming an app config that is fine.
## Managed or bare?
Managed, though both reproductions below are standalone. They need no Expo project, only an `app.config.ts` and a `package.json`.
## Environment
```
OS: macOS 26.5.2
Node: 22.22.2
npm: 10.9.7
expo: 56.0.18 · expo-router: 56.2.15 · react-native: 0.85.3
Expo Workflow: managed
```
Shipped inside `npm i eas-cli@22.0.0`, and identical in `21.8.0`:
| Package | Version |
| --- | --- |
| `@expo/config` (top level) | 55.0.10 |
| `@expo/config` (under `@expo/prebuild-config`) | **11.0.13** |
| `@expo/require-utils` | 55.0.6 |
| `typescript` | **7.0.2** |
| `sucrase` | 3.35.0 |
An SDK 56 project resolves `@expo/config@56.0.13` and `@expo/require-utils@56.1.6`, and reads the same configs without error.
## Steps to reproduce
```sh
mkdir eas-config-repro && cd eas-config-repro
npm init -y
npm i eas-cli@22.0.0
```
Each fixture directory below also needs any `package.json`, for example `{ "name": "minimal", "version": "1.2.3" }`.
---
### Bug 1: `import.meta` in a config breaks the bundled fallback
The fallback in `getManagedApplicationTargetEntitlementsAsync` (`build/project/ios/entitlements.js`) calls `getPrebuildConfigAsync`. That resolves `@expo/prebuild-config`'s nested `@expo/config@11.0.13`, not the `55.0.10` at the top level. Its `evalConfig` is `sucrase.transform(…, { transforms: ['typescript', 'imports'] })` followed by `require-from-string`.
Sucrase cannot downlevel `import.meta`, and neither can tsc, so it survives verbatim into the CommonJS output:
```js
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); …
… createRequire.call(void 0, _nullishCoalesce(import.meta.url, () => ( __filename)))
```
`require-from-string` then calls `module._compile(code, filename)` with no format argument, which leaves the decision to Node's module-syntax detection. It sees `import.meta`, classifies the module as ESM, and the emitted `exports.…` assignments cannot resolve.
fixture/app.config.ts
```ts
import { createRequire } from 'node:module';
import type { ConfigContext, ExpoConfig } from 'expo/config';
const requirePackageJson = createRequire(import.meta.url ?? __filename);
const { version } = requirePackageJson('./package.json') as { version: string };
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'Minimal',
slug: 'minimal',
version,
});
```
```js
// bug1.cjs
const path = require('node:path');
const nested = path.join(__dirname, 'node_modules/@expo/prebuild-config/node_modules/@expo/config');
const { evalConfig } = require(path.join(nested, 'build/evalConfig.js'));
const configFile = path.join(__dirname, 'fixture/app.config.ts');
console.log('loader @expo/config:', require(path.join(nested, 'package.json')).version);
try {
const res = evalConfig(configFile, { projectRoot: path.dirname(configFile), config: {} });
console.log('OK ->', res.config.name);
} catch (error) {
console.log('FAILED ->', error.message.split('\n')[0]);
}
```
```
$ node bug1.cjs
loader @expo/config: 11.0.13
FAILED -> exports is not defined in ES module scope
```
The same fixture loads fine under `@expo/config@56.0.13`. There, `@expo/require-utils@56.x` probes its own CommonJS output with `containsModuleSyntax()` (a `vm.compileFunction` check) and re-transpiles as ESM when that trips. `@expo/config@11.0.13` predates the recovery.
How it looked in a real CI run of `eas build --platform ios`, after an unrelated config-plugin failure:
```
Falling back to the version of "@expo/config" shipped with the EAS CLI.
...
The bundled config fallback also failed with: Error reading Expo config at app.config.ts:
exports is not defined in ES module scope
```
---
### Bug 2: the top-level bundled `@expo/config` cannot transpile any TypeScript config
`@expo/require-utils@55.0.6`'s `loadTypescript()` calls `require('typescript')` inside a `try/catch` that only handles `MODULE_NOT_FOUND`. EAS CLI ships `typescript@7.0.2`, whose `"."` export is `./lib/version.cjs`, a stub with no compiler API:
```js
require('typescript').version // '7.0.2'
typeof require('typescript').transpileModule // 'undefined'
require('typescript').ModuleKind // undefined
```
The stub is truthy, so it passes the guard, and `evalModule` then dereferences `ts.ModuleKind.CommonJS`.
`@expo/require-utils@56.1.6` already has the fix, it just has not reached EAS CLI:
```js
if (typeof _ts?.transpileModule !== 'function') {
_ts = null;
return null;
}
```
`fixture2/app.config.ts` is an ordinary config, with no `import.meta` anywhere in it:
```ts
import type { ConfigContext, ExpoConfig } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'Minimal',
slug: 'minimal',
});
```
```js
// bug2.cjs
const path = require('node:path');
const { getConfig } = require(path.join(__dirname, 'node_modules/@expo/config'));
try {
const { exp } = getConfig(path.join(__dirname, 'fixture2'), { skipSDKVersionRequirement: true });
console.log('OK ->', exp.name);
} catch (error) {
console.log('FAILED ->', error.message.split('\n').filter(Boolean).pop());
}
```
```
$ node bug2.cjs
FAILED -> Cannot read properties of undefined (reading 'CommonJS')
```
---
## Suggested fix
Bumping EAS CLI's `@expo/config`, `@expo/require-utils`, and `@expo/prebuild-config` to the SDK 56 line addresses both. 56.1.6 carries the TypeScript 7 guard for bug 2 and the `containsModuleSyntax()` ESM re-transpile for bug 1. Bug 2 can also be fixed on its own, either by pinning `typescript` to a 5.x or 6.x line inside EAS CLI, or by dropping the dependency so `loadTypescript()` falls through to `module.stripTypeScriptTypes`.
Contributor guide
Assessment
This issue has not been assessed yet.