Skip to content

Bundled @expo/config cannot read a TypeScript app.config.ts (two independent failures in eas-cli 22.0.0) #4205

Description

@maencharacter

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

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:

"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
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,
});
// 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:

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:

if (typeof _ts?.transpileModule !== 'function') {
  _ts = null;
  return null;
}

fixture2/app.config.ts is an ordinary config, with no import.meta anywhere in it:

import type { ConfigContext, ExpoConfig } from 'expo/config';

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: 'Minimal',
  slug: 'minimal',
});
// 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions