Skip to content

Conversation

@kl0tl
Copy link
Member

@kl0tl kl0tl commented Feb 15, 2020

This PR compiles to ES modules instead of CommonJS.

As summarised in #3613:

  • Imports are compiled to namespace imports.
  • Exported identifiers are compiled to named exports.
  • Primes are always escaped in PureScript modules exports but forbidden in foreign modules.
  • Both ES and CommonJS foreign modules are supported.

For instance, given this foreign module:

export var message = "Hello world";

the following PureScript module:

module Main where

import Prelude

import Effect (Effect)
import Effect.Console (log)

foreign import message :: String

main :: Effect Unit
main = log message

compiles to:

// Generated by purs version 0.13.6
import * as $foreign from "./foreign.js";
import * as Effect_Console from "../Effect.Console/index.js";
var main = Effect_Console.log($foreign.message);
export {
    message
} from "./foreign.js";
export {
    main
};

and can then be bundled with purs bundle.

@kl0tl kl0tl mentioned this pull request Feb 15, 2020
@kl0tl
Copy link
Member Author

kl0tl commented Feb 15, 2020

Note that since primes in foreign modules exports are now required to be escaped, this isn’t usable before #3613 (comment) and enough time for the ecosystem to adapt.

@kl0tl kl0tl force-pushed the es-modules branch 3 times, most recently from a5ea971 to f42d247 Compare February 16, 2020 15:14
@triallax
Copy link
Contributor

Isn't the $ after Just unnecesarry right here?

@kl0tl
Copy link
Member Author

kl0tl commented Feb 23, 2020

That $ was indeed redundant!

I’ve rebased the PR on top of #3792 and dropped support for escaped primes in identifiers exported from foreign modules.

@hdgarrood
Copy link
Contributor

@kl0tl thanks very much for tackling this, I'm really excited to give it a try! Just to set expectations, I think this is unlikely to get looked at least until v0.14.0 is out. After that, we might want to wait a while before merging this, to give people time to heed the warnings about primes in FFI identifiers, and also because we probably don't want to put breaking releases out too often. We can assess the effect this will have on the ecosystem, though; if it's not too great, that makes it easier to justify releasing ES modules more quickly after v0.14.0. I suspect ES modules won't affect libraries very much, but the migration might be more difficult for some apps, especially if they have complicated bundling setups.

@kl0tl
Copy link
Member Author

kl0tl commented Apr 15, 2020

Regarding the ecosystem: I’ve submitted pull requests to all impacted libraries in the official package set, is there anything more I could do to help with the transition?

@hdgarrood
Copy link
Contributor

Thanks! I can’t think of anything really, apart from trying to get lots of people with different setups to try this branch out on their apps and report back on how much effort was required to get them working, so that we can better see how much of an impact this will have on the ecosystem, and see if there’s anything else we can do to make the transition smoother. That should wait until after v0.14.0 is out too though, since people will need to update their apps to v0.14.0 first.

@kl0tl
Copy link
Member Author

kl0tl commented Apr 18, 2020

I successfully bundled one of our projects at work with this branch and webpack@4! The resulting bundle weights 18% less and we’ll scrape a few more percents with webpack@5 and sideEffects: false 🎉

I have to upstream a patch to purs-loader but I hadn’t any change to make to the webpack configuration otherwise, beside what was required for testing webpack@5 and sideEffects: false.

I expect bundling the output directory directly with webpack (without purs-loader), rollup or parcel to work as is. I took a look at rollup-plugin-purs but I don’t think it needs anything to work with ES modules. Also I believe this makes zephyr unnecessary for webpack@5 and rollup (based on https://github.com/kl0tl/purescript-es-modules-benchmark) but I’d be interested to try with a real app.

@kl0tl
Copy link
Member Author

kl0tl commented Apr 18, 2020

I was curious about how zephyr would perform on the same project so I did more tests today and bundling CJS modules optimized by zephyr still beats webpack@5 over ES modules 😅

Here’s a summary of my results with webpack@4:

CJS modules ES modules CJS modules + zephyr ES modules + zephyr
502.16 KB 409.36 KB 379.81 KB 352.58 KB (for some reason the bundle is slightly larger, 352.92 KB, with sideEffects: false)

and webpack@5:

CJS modules ES modules CJS modules + zephyr ES modules + zephyr
483.58 KB 394.74 KB (388.17 KB with sideEffects: false) 368.26 KB 340.16 KB (339.31 KB with sideEffects: false)

I’ll compile zephyr against this branch to see how bundling optimized ES modules compares.

Edit: I’ve updated the table with ES modules optimized by zephyr.

@kl0tl
Copy link
Member Author

kl0tl commented Apr 22, 2020

I picked https://github.com/f-f/purescript-react-basic-todomvc as a simple enough but hopefully not too trivial project to test various bundlers configurations with ES modules.

I have successfully tested (with zephyr when possible) so far:

"use strict";

var fs = require('fs');
import * as fs from 'fs';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more idiomatic to write import fs from 'fs' or import { readFileSync } from 'fs' here but purs bundle does not support these syntaxes yet.

var fs = require('fs');
import * as fs from 'fs';

var source = fs.readFileSync(__filename, 'utf-8');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works because the output of purs bundle is interpreted as a CommonJS module by Node.js but __filename is not available in ES modules, so we’ll have to support CommonJS foreign modules at least until language-javascript can parse import.meta.url.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how much __filename is used across the ecosystem? I expect it will ruin at least one person's day if we ship this with a version of language-javascript which can't yet parse import.meta.url.

.travis.yml Outdated
language: node_js
node_js:
- "10"
- "14"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also specify the version of Node.js with a .nvmrc file (https://docs.travis-ci.com/user/languages/javascript-with-nodejs/#specifying-nodejs-versions-using-nvmrc). This would make it easier to enforce the version to use during development (tests won’t pass anymore on <14.13.0).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds fine as long as we aren't requiring people to use nvm. But I think having the tests fail on <14.13.0 is probably fine too. Why do the tests fail? Does the repl still work on older versions of node?

Copy link
Member Author

@kl0tl kl0tl Apr 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests and the REPL need to be able to import CommonJS foreign modules with ES named imports, but this isn’t possible before v14.13.0:

module Example where

example = "Hello world"
> purs repl Example.purs 'bower_components/purescript-*/src/**/*.purs'
Compiling Example
PSCi, version 0.14.0
Type :? for help

> import Example
> example
> node
Welcome to Node.js v14.2.0.
Type ".help" for more information.
> import('./.psci_modules/index.js')
Promise { <pending> }
> (node:28607) UnhandledPromiseRejectionWarning:
export { cons, join, showArrayImpl, showCharImpl, showIntImpl, showNumberImpl, showStringImpl } from "./foreign.cjs";
         ^^^^
SyntaxError: The requested module './foreign.cjs' does not provide an export named 'cons'
    at ModuleJob._instantiate (internal/modules/esm/module_job.js:92:21)
    at async ModuleJob.run (internal/modules/esm/module_job.js:107:20)
    at async Loader.import (internal/modules/esm/loader.js:179:24)

I did not reconsider the version requirement when introducing ES wrappers for CommonJS foreign modules though, but we could import the default export of the CommonJS foreign module in the ES wrapper and re-export properties of the default export with export declarations:

// foreign.cjs
"use strict";

exports.log = function (s) {
  return function () {
    console.log(s);
  };
};
// foreign.js
import $foreign from "./foreign.cjs";

var log = $foreign.log;

export { log };

This has the advantage of working all the way back to Node.js v12.17.0 (earlier v12 versions need --experimental-modules) which is great news since support for v10 stops at the end of the month!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach sounds good to me 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How far back is --experimental-modules understood?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Back in 2017, Node.js 8.9.0 shipped experimental support for ECMAScript modules, known for their import and export statements. This support was behind the flag --experimental-modules.

https://nodejs.medium.com/announcing-a-new-experimental-modules-1be8d2d6c2ff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We’re really interested in the implementation of --experimental-modules that was shipped with v12.0.0, this flag is of no use to us in v10.0.0 and earlier.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if we can expect this approach to work when supplying --experimental-modules with any version of node.js >= v12.0.0, and if earlier versions of node are EOL, this sounds fine to me. I think we might want to check the version of node we are using in the repl and supply that flag where necessary. Perhaps we should test on node v12 as well as v14 for now as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REPL and the tests now throw on unsupported Node.js versions (<12.0.0) and provide the --experimental-module flag for versions >=12.0.0 && <12.7.0.

Perhaps we should test on node v12 as well as v14 for now as well?

We don’t rely on the behaviour introduced in v14.13.0 anymore, so testing on v12.0.0 and the latest v12 might be enough?

@kl0tl
Copy link
Member Author

kl0tl commented Jul 3, 2021

I merged master and implemented the strategy described in this comment to support Node.js >=12.0.0!

@JordanMartinez
Copy link
Contributor

Nice!

Just FYI. CI failures are caused by hlint finding a few things.

@squidCatx
Copy link

what is the reason to stop merge this

@JordanMartinez
Copy link
Contributor

@cocoddy It's not yet done. I don't recall everything, but one open question is what to do about purs bundle.

@thomashoneyman
Copy link
Member

thomashoneyman commented Jan 17, 2022

Node.js has made great progress on the interoperability of CommonJS and ES modules though: ES namespace imports are able to import CommonJS exports since v14.13.0, which is great news for us! Most of my work this weekend has been about ensuring CommonJS and ES foreign modules can coexist natively on Node.js

I think we should support only ES modules in FFI for simplicity's sake.

This means that CommonJS modules would not be accepted in FFI files, and we'd attempt no automatic conversions or processing to have ES modules and CommonJS work together. As far as the compiler is concerned it's all ES modules in and ES modules out.

However, the world doesn't work this way: plenty of browser and Node projects currently rely on CommonJS and we need to be sure that they continue to work even if the compiler only accepts ES modules. Still, I think we're in the clear:

  1. We can omit the browser part of this question because browsers do not support CommonJS; you will have to use a bundler if you need CommonJS for the browser, and bundlers support both module types.
  2. I believe we can omit the Node part of this question, since Node supports both module types from Node 14 onward and we have evidence from the node-challenge that mixing module types works fine.

To use a CommonJS module in your FFI, you would need to take one of two routes. The first is where the CommonJS is using named exports:

// commonjs uses named exports
// mymodule.js
exports.foo = ...
exports.bar = ...

// es modules FFI file can't do named imports directly; you'd have to import the
// full module then pick off what you need
import mymodule from "mymodule.js"

export const foo = mymodule.foo;
export const bar = mymodule.bar;

The second is where the CommonJS is using a default export:

// commonjs uses default export
// mymodule.js
module.exports = ...

// es modules
import mymodule from "mymodule.js"

export default mymodule;

Either way, it's possible to write your FFI in such a way that you only write an ES module, and if you need to pull in CommonJS dependencies then you rely on either a bundler in the case of the browser (which is required anyway), or rely on Node's module resolutions. Either way we can get away with only supporting ES modules.

Further reading:
https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1

@tiye
Copy link

tiye commented Jan 18, 2022

better using

import mymodule from "./mymodule.js"

since "mymodule.js" is treated as a package(then an extra package.json will be involved), rather than a file.

@rhendric
Copy link
Member

Closed in favor of #4232.

@rhendric rhendric closed this Feb 27, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.