node-fs-extra icon indicating copy to clipboard operation
node-fs-extra copied to clipboard

Support ESM

Open RyanZim opened this issue 5 years ago • 22 comments

Node v13.2.0+ has experimental ESM (native module) support without any kind of flag. This means that you can now do:

import { readFile } from 'fs'

However, if you try:

import { readFile } from 'fs-extra'

You get an error, because fs-extra is CJS-only. (You cannot export named exports from a CJS file) If we are going to remain a drop-in replacement for fs, we need to support this.

RyanZim avatar Jan 31 '20 20:01 RyanZim

Discussed with @jprichardson and agreed to postpone until ESM support is no longer consider experimental in Node; due to the challenges of implementing this.

RyanZim avatar Feb 13 '20 20:02 RyanZim

ESM will soon be lts according to the release schedule, node 14 will be released on October 20.

ChocolateLoverRaj avatar Oct 10 '20 02:10 ChocolateLoverRaj

I think the release date got postponed to October 27.

ChocolateLoverRaj avatar Oct 21 '20 20:10 ChocolateLoverRaj

@RyanZim @jprichardson Node 14 is now LTS. That means that esmodules are no longer experimental.

ChocolateLoverRaj avatar Oct 29 '20 23:10 ChocolateLoverRaj

@ChocolateLoverRaj ESM is still listed experimental in the docs: https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_modules_ecmascript_modules

RyanZim avatar Oct 30 '20 13:10 RyanZim

See also https://www.skypack.dev/view/fs-extra for hints/ideas.

(Of all the packages I have in my app, fs-extra was the only one that didn't work with snowpack dev due to what I presume is rollup/plugin-commonjs not supporting the rather dynamic require/import/export syntax.)

beorn avatar Nov 01 '20 16:11 beorn

Node v15.3.0 makes ESM stable, so we should get on with this.

RyanZim avatar Jan 18 '21 18:01 RyanZim

Is there any idea of how to tackle this? A lot of other nom packages are quickly dropping commonjs support and it would be great to support native ESM syntax on fs-extra.

LukeSheard avatar Jul 22 '21 13:07 LukeSheard

There's a few holdups:

  1. We depend on graceful-fs, which is not ESM
  2. Handling of functions that are not in all versions of node
  3. Our promise polyfill behavior

See https://github.com/jprichardson/node-fs-extra/pull/854 for more detailed discussion.

RyanZim avatar Jul 22 '21 13:07 RyanZim

  1. It's still possible to import commonjs modules in ESM using import gracefulFS from 'graceful-fs which will fallback to whatever module.exports is, so I'm not sure this is an issue.
  2. I saw some comments about this on the PR - but would ultimately be for you to decide as the owner of the repo. My own opinion is that we don't currently get static exports defined by platform with commonjs - I'm not sure this makes it a blocker for enabling ESM package format.
  3. I didn't see anything about this - although I may have overlooked it. How is this related to ESM builds.

I'd be happy to work on this directly - let me know if there's anything I can do.

LukeSheard avatar Jul 22 '21 15:07 LukeSheard

All of these concerns basically tie back to doing named exports when we don't know the function list ahead of time. Ideally we'd just do export * from 'fs', but we can't, because 1) we use graceful-fs, which requires importing as an object; 3) we add promise support to all functions. And because of 2), we can't just hard-code a static list of functions.

RyanZim avatar Jul 22 '21 17:07 RyanZim

While I agree that it would be great to have static exports per platform - it's currently not how the module behaves with commonjs. The only way would be to maintain a platform dependent version of each module and link to it per platform either ahead of time of time or on installation.

LukeSheard avatar Jul 23 '21 20:07 LukeSheard

The dependency on the graceful-fs is also important here.

Currently, the bug fixed in this PR prevents us from bundling fs-extra using Parcel. https://github.com/isaacs/node-graceful-fs/pull/206

aminya avatar Jul 24 '21 22:07 aminya

@RyanZim Potentially the best way to do this then is to generate a file per platform and then add a test case to ensure that all the exposed functions from fs are also exposed from fs-extra? While this isn't necessary the nicest possible solution it would guarantee static imports like you desire?

LukeSheard avatar Jul 31 '21 13:07 LukeSheard

Potentially the best way to do this then is to generate a file per platform and then add a test case to ensure that all the exposed functions from fs are also exposed from fs-extra? While this isn't necessary the nicest possible solution it would guarantee static imports like you desire?

The problem is choosing which generated file to use; AFAIK, there's no way we can do that dynamically.

RyanZim avatar Jul 31 '21 23:07 RyanZim

Yeah - there's no way to do dynamic binding of static exports. So either there's a postinstall step to select the right file at install time or export everything but leave variables undefined (or fill them with a function which returns a platform error)

LukeSheard avatar Aug 02 '21 10:08 LukeSheard

I was stuck with this issue and played around with possible solutions. This compatibility bridge seems to be fine for now.

Before

🗎 test/test.js

import {mkdirp, writeFile, readJson} from "fs-extra"

After

🗎 test/test.js

import {mkdirp, writeFile, readJson} from "../src/lib/esm/fs-extra.js"

🗎 src/lib/esm/fs-extra.js

import {createRequire} from "node:module"

const require = createRequire(import.meta.url)
const commonJsModule = require("fs-extra")

export const {mkdirp} = commonJsModule
export const {writeFile} = commonJsModule
export const {readJson} = commonJsModule

Jaid avatar Sep 20 '21 14:09 Jaid

@Jaid Your bridge doesn't solve any of the issues we're dealing with, since it requires explicitly listing all functions, which we can't do.

RyanZim avatar Sep 20 '21 18:09 RyanZim

@jprichardson @manidlou @JPeer264 I've been doing some thinking about this issue, and I've come up with a compromise proposal that would allow us to ship basic ESM support now, while still not barring us from "doing it right" in a future release.

Our problems basically all center around the fact that we're trying to export named exports for native fs functions, which can't be done except via export * from 'fs', which we can't do, because we're not shipping vanilla fs. There's no actual issues with shipping named exports for our own fs-extra-specific functions. My proposal is simple: don't ship named exports for native fs functions. We can provide this limited ESM support via fs-extra/esm so it's explicit that /esm is not a drop-in replacement for fs (we already don't support fs/promises, so there's precedent for our drop-in support not extending to subpaths).

What does this look like practically? CJS (require('fs-extra')) works exactly how it works today. importing 'fs-extra' works exactly how it works today:

import fs from 'fs-extra' // Works, just like it does today
import { copy } from 'fs-extra' // Doesn't work, just like today

However, there's a new fs-extra/esm module, that provides the magic:

require('fs-extra/esm')
// Doesn't work, can't require an ESM module

import fs from 'fs-extra/esm'
// Works, just like `import fs from 'fs-extra'` does today

import { copy } from 'fs-extra/esm'
// Works! This is the new feature

import { writeFile, writeFileSync } from 'fs-extra/esm' 
// Doesn't work, since we don't export named exports of native fs functions

// If you're explicitly writing out the name of each function you're using via named imports,
// you will have to explicitly import what you want from the correct source, like so:
import { writeFile } from 'fs/promises'
import { writeFileSync } from 'fs'
import { copy } from 'fs-extra'
// This works

Pros:

  1. We get basic ESM support shipped
  2. We don't break the expectation of fs-extra itself being at parity with fs any more than it's already broken today.

Cons:

  1. Users have to import named fs functions directly from fs or fs/promises. This is somewhat painful, but acceptable, as fs/promises forces you to do the same thing.

Let me know your thoughts, or if I've missed anything in my plan here.

RyanZim avatar Sep 20 '21 19:09 RyanZim

@Jaid Your bridge doesn't solve any of the issues we're dealing with, since it requires explicitly listing all functions, which we can't do.

@RyanZim Sorry, I wasn't clear enough about my post. This was not intended to be a solution for supporting ESM in fs-extra. This is a solution for people who already migrated their own project to ESM and want to import fs-extra v10.0.0.

Jaid avatar Sep 20 '21 21:09 Jaid

Would be nice to see this implemented.

beorn avatar Feb 03 '22 01:02 beorn

I think that in 6+ months or so all maintained nodejs versions (16+) will support ESM natively.

bd82 avatar Aug 25 '22 12:08 bd82

I finally decided to maintain a copy of this module myself in monorepo, automatically generate the above adaptation code according to fs-extra, and share it here

import fsExtra from 'fs-extra'
import { builders as b, namedTypes as n } from 'ast-types'
import { prettyPrint } from 'recast'
import tsParser from 'recast/parsers/typescript'
import path from 'path'
import { fileURLToPath } from 'url'
import { difference } from 'lodash-es'

function scan() {
  const excludes = [
    'FileReadStream',
    'FileWriteStream',
    '_toUnixTimestamp',
    'F_OK',
    'R_OK',
    'W_OK',
    'X_OK',
    'gracefulify',
  ]
  return difference(Object.keys(fsExtra), excludes)
}

function generate(list: string[]) {
  return b.program([
    b.importDeclaration([b.importDefaultSpecifier(b.identifier('fsExtra'))], b.literal('fs-extra')),
    ...list.map((s) =>
      b.exportNamedDeclaration(
        b.variableDeclaration('const', [
          b.variableDeclarator(b.identifier(s), b.memberExpression(b.identifier('fsExtra'), b.identifier(s))),
        ]),
      ),
    ),
  ])
}

export async function build() {
  const list = scan()
  const ast = generate(list)
  const code = prettyPrint(ast, { parser: tsParser }).code
  await fsExtra.writeFile(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'index.ts'), code)
}

Output

import fsExtra from 'fs-extra'
export const appendFile = fsExtra.appendFile
export const appendFileSync = fsExtra.appendFileSync
export const access = fsExtra.access
export const accessSync = fsExtra.accessSync
export const chown = fsExtra.chown
export const chownSync = fsExtra.chownSync
export const chmod = fsExtra.chmod
export const chmodSync = fsExtra.chmodSync
export const close = fsExtra.close
export const closeSync = fsExtra.closeSync
export const copyFile = fsExtra.copyFile
export const copyFileSync = fsExtra.copyFileSync
export const cp = fsExtra.cp
export const cpSync = fsExtra.cpSync
export const createReadStream = fsExtra.createReadStream
export const createWriteStream = fsExtra.createWriteStream
export const exists = fsExtra.exists
export const existsSync = fsExtra.existsSync
export const fchown = fsExtra.fchown
export const fchownSync = fsExtra.fchownSync
export const fchmod = fsExtra.fchmod
export const fchmodSync = fsExtra.fchmodSync
export const fdatasync = fsExtra.fdatasync
export const fdatasyncSync = fsExtra.fdatasyncSync
export const fstat = fsExtra.fstat
export const fstatSync = fsExtra.fstatSync
export const fsync = fsExtra.fsync
export const fsyncSync = fsExtra.fsyncSync
export const ftruncate = fsExtra.ftruncate
export const ftruncateSync = fsExtra.ftruncateSync
export const futimes = fsExtra.futimes
export const futimesSync = fsExtra.futimesSync
export const lchown = fsExtra.lchown
export const lchownSync = fsExtra.lchownSync
export const lchmod = fsExtra.lchmod
export const lchmodSync = fsExtra.lchmodSync
export const link = fsExtra.link
export const linkSync = fsExtra.linkSync
export const lstat = fsExtra.lstat
export const lstatSync = fsExtra.lstatSync
export const lutimes = fsExtra.lutimes
export const lutimesSync = fsExtra.lutimesSync
export const mkdir = fsExtra.mkdir
export const mkdirSync = fsExtra.mkdirSync
export const mkdtemp = fsExtra.mkdtemp
export const mkdtempSync = fsExtra.mkdtempSync
export const open = fsExtra.open
export const openSync = fsExtra.openSync
export const opendir = fsExtra.opendir
export const opendirSync = fsExtra.opendirSync
export const readdir = fsExtra.readdir
export const readdirSync = fsExtra.readdirSync
export const read = fsExtra.read
export const readSync = fsExtra.readSync
export const readv = fsExtra.readv
export const readvSync = fsExtra.readvSync
export const readFile = fsExtra.readFile
export const readFileSync = fsExtra.readFileSync
export const readlink = fsExtra.readlink
export const readlinkSync = fsExtra.readlinkSync
export const realpath = fsExtra.realpath
export const realpathSync = fsExtra.realpathSync
export const rename = fsExtra.rename
export const renameSync = fsExtra.renameSync
export const rm = fsExtra.rm
export const rmSync = fsExtra.rmSync
export const rmdir = fsExtra.rmdir
export const rmdirSync = fsExtra.rmdirSync
export const stat = fsExtra.stat
export const statSync = fsExtra.statSync
export const symlink = fsExtra.symlink
export const symlinkSync = fsExtra.symlinkSync
export const truncate = fsExtra.truncate
export const truncateSync = fsExtra.truncateSync
export const unwatchFile = fsExtra.unwatchFile
export const unlink = fsExtra.unlink
export const unlinkSync = fsExtra.unlinkSync
export const utimes = fsExtra.utimes
export const utimesSync = fsExtra.utimesSync
export const watch = fsExtra.watch
export const watchFile = fsExtra.watchFile
export const writeFile = fsExtra.writeFile
export const writeFileSync = fsExtra.writeFileSync
export const write = fsExtra.write
export const writeSync = fsExtra.writeSync
export const writev = fsExtra.writev
export const writevSync = fsExtra.writevSync
export const Dir = fsExtra.Dir
export const Dirent = fsExtra.Dirent
export const Stats = fsExtra.Stats
export const ReadStream = fsExtra.ReadStream
export const WriteStream = fsExtra.WriteStream
export const constants = fsExtra.constants
export const promises = fsExtra.promises
export const copy = fsExtra.copy
export const copySync = fsExtra.copySync
export const emptyDirSync = fsExtra.emptyDirSync
export const emptydirSync = fsExtra.emptydirSync
export const emptyDir = fsExtra.emptyDir
export const emptydir = fsExtra.emptydir
export const createFile = fsExtra.createFile
export const createFileSync = fsExtra.createFileSync
export const ensureFile = fsExtra.ensureFile
export const ensureFileSync = fsExtra.ensureFileSync
export const createLink = fsExtra.createLink
export const createLinkSync = fsExtra.createLinkSync
export const ensureLink = fsExtra.ensureLink
export const ensureLinkSync = fsExtra.ensureLinkSync
export const createSymlink = fsExtra.createSymlink
export const createSymlinkSync = fsExtra.createSymlinkSync
export const ensureSymlink = fsExtra.ensureSymlink
export const ensureSymlinkSync = fsExtra.ensureSymlinkSync
export const readJson = fsExtra.readJson
export const readJsonSync = fsExtra.readJsonSync
export const writeJson = fsExtra.writeJson
export const writeJsonSync = fsExtra.writeJsonSync
export const outputJson = fsExtra.outputJson
export const outputJsonSync = fsExtra.outputJsonSync
export const outputJSON = fsExtra.outputJSON
export const outputJSONSync = fsExtra.outputJSONSync
export const writeJSON = fsExtra.writeJSON
export const writeJSONSync = fsExtra.writeJSONSync
export const readJSON = fsExtra.readJSON
export const readJSONSync = fsExtra.readJSONSync
export const mkdirs = fsExtra.mkdirs
export const mkdirsSync = fsExtra.mkdirsSync
export const mkdirp = fsExtra.mkdirp
export const mkdirpSync = fsExtra.mkdirpSync
export const ensureDir = fsExtra.ensureDir
export const ensureDirSync = fsExtra.ensureDirSync
export const move = fsExtra.move
export const moveSync = fsExtra.moveSync
export const outputFile = fsExtra.outputFile
export const outputFileSync = fsExtra.outputFileSync
export const pathExists = fsExtra.pathExists
export const pathExistsSync = fsExtra.pathExistsSync
export const remove = fsExtra.remove
export const removeSync = fsExtra.removeSync

Using

import { readFile } from '@liuli-util/fs-extra'

console.log(await readFile(import.meta.url))

Hopefully after fs-extra supports esm I can simply switch back

rxliuli avatar Sep 29 '22 16:09 rxliuli

Any movement on this? Some libs are starting to enforce hard dependencies on ESM so it's becoming challenging to get everything to play nice together.

dscalzi avatar Oct 05 '22 19:10 dscalzi

Unfortunately, I'm abandoning fs-extra now because we have switched to ESM only. It only showed up at runtime...TypeError: o.readFileSync is not a function

It would be great if there were an ESM bundle...

rosskevin avatar Oct 06 '22 22:10 rosskevin

Implementation in https://github.com/jprichardson/node-fs-extra/pull/974, slated for release in v11; please review.

RyanZim avatar Oct 25 '22 20:10 RyanZim

Not really solved, it doesn't work whether I use import { readdir } from 'fs-extra' or import { readdir } from 'fs-extra/esm'

ref: https://github.com/rxliuli/fs-extra-demo

rxliuli avatar Dec 10 '22 03:12 rxliuli

readdir is a native fs function; as per https://github.com/jprichardson/node-fs-extra#esm, you need to import these functions directly from fs or fs/promises for named imports.

RyanZim avatar Dec 10 '22 20:12 RyanZim

Types still need to be updated https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/fs-extra. Right now when using typescript, the esm export is basically an any, so you won't know which imports are wrong until runtime.

Posted this discussion but it seems to not be getting any traction. https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/63476

dscalzi avatar Dec 11 '22 01:12 dscalzi