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'
}