govuk-frontend: ES Modules can't be imported without a bundler

Hello! šŸ‘‹

Loved seeing the ES Modules support land yesterday 😊

Description of the issue

I’m unfortunately seeing an Error [ERR_PACKAGE_PATH_NOT_EXPORTED] thrown without a bundler

Steps to reproduce the issue

This happens with a plain native Node.js import from another *.mjs file

Current (see package.json) Missing the main or default export

"exports": {
  "import": "./govuk-esm/all.mjs"
}

Fixed (see exports documentation) Provided a main or default export for both import and require()

"exports": {
  ".": {
    "import": "./govuk-esm/all.mjs",
    "require": "./govuk/all.js"
  }
}

Actual vs expected behaviour

Node’s resolver node:internal/modules/esm/resolve doesn’t have enough info to import 'govuk-frontend'

Unlike the Import JavaScript using a bundler guidance regarding webpack, I can’t teach Node.js to break the ESM rules.

With the fix above, I sadly hit another challenge. Node.js wouldn’t attempt to resolve .mjs files as there’s no package.json "type": "module" and extensions are mandatory for CommonJS and ESM compatibility 😬

Mandatory file extensions A file extension must be provided when using the import keyword. Directory indexes (e.g. './startup/index.js') must also be fully specified.

This behavior matches how import behaves in browser environments, assuming a typically configured server.

I wasn’t fully out of options though.

I tried the new ESM aware TypeScript compiler but it also had issues importing without extensions. Microsoft have added some good release notes on type in package.json and New Extensions plus challenges involved.

Hope these notes help, it’s fab to see what’s coming šŸš€

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 18 (16 by maintainers)

Most upvoted comments

@vanitabarrett Here’s a super minimal one: https://github.com/colinrotherham/govuk-frontend-esm-sample

npm ci
npm start

Or via mocha

npm test

Ah interesting, thanks! Will take a look @colinrotherham

@colinrotherham Would you mind trying out this pre-release, when you get some time? It includes file extensions for all imported component JS files, so I’m hoping that may fix the issue šŸ¤ž

npm install --save "alphagov/govuk-frontend#872b5c1f"

Note: the implementation of this may change - we’re currently weighing up shipping as .mjs or .js to make things simpler. But either way, this should help us understand if specifying all file extensions will fix the issue.

Not sure who started the package.json "sass" field really!

I had a quick look and your "sass" field was added in June 2018 (by @nickcolley ā­ļø) but it’s likely one of the early Compass/Grunt/Gulp node-sass { importer } plugins that used "sass" too

A bit later sass-loader for webpack added "sass" support in December 2018

There’s also the original (~2015) Eyeglass importer from the Sass people:

Foundation https://github.com/foundation/foundation-sites/blob/develop/package.json#L142

"eyeglass": {
  "name": "foundation",
  "sassDir": "scss",
  "needs": ">=0.8.0",
  "exports": false
},

Must have followed on from frameworks and pattern library tools adopting style? (Made popular by Browserify + Parcelify, PostCSS too)

Package entry points

The docs aren’t brilliant and other tooling won’t necessarily support the full spec https://nodejs.org/api/packages.html#package-entry-points

The webpack resolver appears to prefer "exports" so explains why excluding "sass" caused https://github.com/alphagov/govuk-frontend/issues/2645

Shorthand

Using the shorthand (following the documentation) would give us:

"exports": {
  "import": "./govuk-esm/all.mjs",
  "require": "./govuk/all.js",
  "sass": "./govuk/all.scss",
  "style": "./dist/govuk-frontend-4.1.0.min.css",
  "browser": "./dist/govuk-frontend-4.1.0.min.js",
  "default": "./dist/govuk-frontend-4.1.0.min.js"
}

Regarding "default" being a more universal implementation (with polyfills etc perhaps):

When using environment branches, always include a ā€œdefaultā€ condition where possible. Providing a ā€œdefaultā€ condition ensures that any unknown JS environments are able to use this universal implementation, which helps avoid these JS environments from having to pretend to be existing environments in order to support packages with conditional exports.

Longhand

But those "./govuk/" "./govuk-esm/" package entry points were for compatibility to let tools know ā€œthese paths are also availableā€ without trying to resolve from "main" or "module" instead

"exports": {
  ".": {
    "import": "./govuk-esm/all.mjs",
    "require": "./govuk/all.js",
    "sass": "./govuk/all.scss",
    "style": "./dist/govuk-frontend-4.1.0.min.css",
    "browser": "./dist/govuk-frontend-4.1.0.min.js",
    "default": "./dist/govuk-frontend-4.1.0.min.js"
  },
  "./govuk/": "./govuk/",
  "./govuk-esm/": "./govuk-esm/"
}

But no, it doesn’t open up that directory/path

You’ll already be able to import a specific Sass stylesheet or nested polyfill directly

Typically you’d use them for backwards compatibility when files move:

"exports": {
  ".": "./dist/index.js",
  "./old-export": "./dist/legacy/new-export.js"
}

Found the code that’s causing us issues

We have a monorepo with a shared webpack.config.mjs. Some local packages need to know where govuk-frontend is installed (it could be local to the package, or hoisted to a higher node_modules directory) so we run:

import { createRequire } from 'module'

// Avoids Node.js --experimental-import-meta-resolve
const require = createRequire(import.meta.url)

// Locate package
const pkgGovukFrontend = dirname(require.resolve('govuk-frontend'))

This is where Node.js throws Error [ERR_PACKAGE_PATH_NOT_EXPORTED]

[webpack-cli] Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in /path/to/project/node_modules/govuk-frontend/package.json
    at new NodeError (node:internal/errors:372:5)
    at throwExportsNotFound (node:internal/modules/esm/resolve:472:9)
    at packageExportsResolve (node:internal/modules/esm/resolve:693:7)
    at resolveExports (node:internal/modules/cjs/loader:482:36)
    at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
    at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
    at Function.resolve (node:internal/modules/cjs/helpers:108:19)
    at file:///path/to/project/packages/frontend-assets/webpack.config.mjs:9:42
    at ModuleJob.run (node:internal/modules/esm/module_job:198:25)
    at async Promise.all (index 0) {
  code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
}