├── .babelrc.js
├── .editorconfig
├── .gitignore
├── index.html
├── package.json
├── readme.md
├── scripts
├── webpack-modern-resolution-plugin.js
└── webpack-module-nomodule-plugin.js
├── src
├── App.js
├── Form.js
├── Main.js
└── index.js
├── webpack.config.js
└── yarn.lock
/.babelrc.js:
--------------------------------------------------------------------------------
1 | const plugins = [
2 | "@babel/plugin-syntax-dynamic-import",
3 | "@babel/plugin-proposal-export-default-from",
4 | '@babel/plugin-transform-react-jsx',
5 | ];
6 |
7 | module.exports = {
8 | env: {
9 | legacy: {
10 | presets: [
11 | [
12 | "@babel/preset-env", {
13 | exclude: ["@babel/plugin-transform-typeof-symbol"],
14 | modules: false,
15 | loose: true,
16 | corejs: 3,
17 | targets: {
18 | browsers: ["last 2 versions", "ie >= 11"]
19 | },
20 | useBuiltIns: 'entry',
21 | }
22 | ]
23 | ],
24 | plugins: [
25 | ...plugins,
26 | ["@babel/plugin-transform-runtime", { corejs: 3 }]
27 | ],
28 | },
29 | modern: {
30 | presets: ['@babel/preset-modules'],
31 | plugins,
32 | }
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs
2 | # See also: editorconfig.org
3 |
4 | root = true
5 |
6 | [*]
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | yarn-error.log
4 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Modern / Legacy POC
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "experimentmodules",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "prebuild": "cross-env rimraf dist",
8 | "build": "cross-env NODE_ENV=production webpack",
9 | "dev": "cross-env NODE_ENV=development webpack-dev-server"
10 | },
11 | "author": "",
12 | "devDependencies": {
13 | "@babel/core": "7.7.2",
14 | "@babel/plugin-proposal-export-default-from": "7.5.2",
15 | "@babel/plugin-syntax-dynamic-import": "7.2.0",
16 | "@babel/plugin-transform-react-jsx": "7.7.0",
17 | "@babel/plugin-transform-runtime": "7.6.2",
18 | "@babel/preset-env": "7.7.1",
19 | "@babel/preset-modules": "0.1.0",
20 | "babel-loader": "8.0.6",
21 | "cross-env": "5.2.0",
22 | "exports-loader": "0.7.0",
23 | "fs-extra": "7.0.1",
24 | "html-webpack-plugin": "3.2.0",
25 | "imports-loader": "0.8.0",
26 | "rimraf": "2.6.3",
27 | "terser-webpack-plugin": "2.2.1",
28 | "webpack": "4.41.2",
29 | "webpack-cli": "3.3.10",
30 | "webpack-dev-server": "3.9.0",
31 | "webpack-module-nomodule-plugin": "0.1.0",
32 | "webpack-modules": "1.0.0",
33 | "webpack-syntax-resolver-plugin": "0.0.1"
34 | },
35 | "dependencies": {
36 | "@babel/runtime-corejs3": "7.7.2",
37 | "core-js": "3.4.1",
38 | "hooked-form": "3.2.0",
39 | "native-url": "^0.2.1",
40 | "preact": "next",
41 | "whatwg-fetch": "3.0.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Module builds
2 |
3 | This proof of concept shows us:
4 |
5 | 1. the power of a module build vs a legacy one (smaller and faster)
6 | 2. the endless possibilities of this approach
7 |
8 | One thing that would need to evolve in the community for this approach to work is to
9 | get rid off the notion that library authors should decide what the minimum down transpiled
10 | code is for their distribution.
11 |
12 | This allows developers to choose their crowd and transpile down how much they want to.
13 |
14 | To see this code in action:
15 |
16 | 1. `yarn build`
17 | 2. `cd dist && http-server -o`
18 | 3. open in chrome, look at network tab
19 | 4. open in IE/Safari and look at network tab.
20 |
21 | ```
22 | Evergreen
23 | main: 2.38KiB
24 | vendors: 48KiB
25 |
26 | Nevergreen
27 | main: 2.85KiB
28 | vendors: 78KiB
29 | fetch-polyfill: 8.7KiB
30 | ```
31 |
32 | Total legacy: 89.55KiB
33 | Total vendors: 50.40KiB
34 |
--------------------------------------------------------------------------------
/scripts/webpack-modern-resolution-plugin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const fs = require('fs');
5 |
6 | const ID = 'ModernResolverPlugin';
7 |
8 | const defaultIgnoredModules = ['core-js'];
9 |
10 | class ModernResolverPlugin {
11 |
12 | constructor({ ignoredModules = [], syntaxTarget = 'esmodules' } = {}) {
13 | this.cache = {};
14 | this.target = syntaxTarget;
15 | this.ignoredModules = [...defaultIgnoredModules, ...ignoredModules];
16 | }
17 |
18 | apply(resolver) {
19 | resolver.getHook('describedResolve').tapAsync(ID, (request, context, callback) => {
20 | const modernPath = this.resolveModulePath(request.request);
21 | if (modernPath) {
22 | return resolver.doResolve(
23 | // Continue in the resolve hook.
24 | resolver.getHook("resolve"),
25 | // Take our new request!
26 | { ...request, request: modernPath },
27 | // Give a descriptive text in case of errors.
28 | `resolve ${request.request} to ${modernPath}`,
29 | // Pass our context on.
30 | context,
31 | // Callback time!!!
32 | (err, result) => {
33 | // Oh we have an error this is not well, exit the process.
34 | if (err) callback(err);
35 | // Prevent resolving twice (undefiend result), this is done
36 | // by calling our callback with two null values
37 | if (result === undefined) return callback(null, null);
38 | // If we want to use this result call it with no error but a result!
39 | callback(null, result);
40 | }
41 | );
42 | }
43 | // There is no modern path just continue.
44 | return callback();
45 | });
46 | }
47 |
48 | resolveModulePath(moduleName) {
49 | const nodeModulesPath = path.resolve(`${process.cwd()}/node_modules/`);
50 | if (this.ignoredModules.includes(moduleName) || this.ignoredModules.includes(moduleName.split('/')[0])) return false;
51 | if (moduleName.startsWith('./') || moduleName.startsWith('../') || moduleName.includes('.modern')) return false;
52 | if (this.exists === undefined || this.exists) {
53 | if (this.cache[moduleName]) return this.cache[moduleName];
54 | // does our node_modules path exist?
55 | this.exists = fs.existsSync(nodeModulesPath);
56 | if (!this.exists) return false;
57 | // Get all our modules.
58 | const contents = fs.readdirSync(nodeModulesPath);
59 | // See if our request name exists.
60 | let moduleExists;
61 | if (moduleName.split('/').length > 0) {
62 | const mName = moduleName.split('/')[0];
63 | moduleExists = contents.find((name) => name === mName)
64 | } else {
65 | moduleExists = contents.find((name) => name === moduleName)
66 | }
67 | if (!moduleExists) return false;
68 | let moduleContents;
69 |
70 | // Get the files from the libraray
71 | if (moduleName.split('/').length > 0) {
72 | moduleContents = fs.readdirSync(path.resolve(nodeModulesPath, ...moduleName.split('/')));
73 | } else {
74 | moduleContents = fs.readdirSync(path.resolve(nodeModulesPath, moduleName));
75 | }
76 | // Get pkg.json
77 | const pkg = moduleContents.find((name) => name === 'package.json');
78 | if (!pkg) return false;
79 |
80 | let fields;
81 | if (moduleName.split('/').length > 0) {
82 | fields = JSON.parse(fs.readFileSync(path.resolve(nodeModulesPath, ...moduleName.split('/'), 'package.json')));
83 | } else {
84 | fields = JSON.parse(fs.readFileSync(path.resolve(nodeModulesPath, moduleName, 'package.json')));
85 | }
86 |
87 | if (!fields.syntax) return false
88 | if (!fields.syntax[this.target]) return false;
89 |
90 | if (moduleName.split('/').length > 0) {
91 | this.cache[moduleName] = path.resolve(nodeModulesPath, ...moduleName.split('/'), fields.syntax.esmodules);
92 | } else {
93 | this.cache[moduleName] = path.resolve(nodeModulesPath, moduleName, fields.syntax.esmodules);
94 | }
95 | return path.resolve(nodeModulesPath, moduleName, fields.syntax.esmodules);
96 | }
97 | }
98 | }
99 |
100 | module.exports = ModernResolverPlugin;
101 |
--------------------------------------------------------------------------------
/scripts/webpack-module-nomodule-plugin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 | const fs = require('fs-extra');
6 |
7 | const ID = 'html-webpack-esmodules-plugin';
8 |
9 | const selfScript = `self.modern=1`;
10 | const makeLoadScript = (modern, legacy) => `
11 | addEventListener('load', function() {
12 | ${(modern.length > legacy.length ? modern : legacy).reduce((acc, _m, i) => `
13 | ${acc}$loadjs(${modern[i] ? `"${modern[i].attributes.src}"` : undefined}, ${legacy[i] ? `"${legacy[i].attributes.src}"` : undefined})
14 | `, '').trim()}
15 | })
16 | function $loadjs(e,d,c){c=document.createElement("script"),self.modern?(e && (c.src=e,c.type="module")):d && (c.src=d),c.src && document.head.appendChild(c)}
17 | `;
18 |
19 | class HtmlWebpackEsmodulesPlugin {
20 | constructor(mode = 'modern') {
21 | switch (mode) {
22 | case 'module':
23 | case 'modern':
24 | this.mode = 'modern';
25 | break;
26 | case 'nomodule':
27 | case 'legacy':
28 | this.mode = 'legacy';
29 | break;
30 | default:
31 | throw new Error(`The mode has to be one of: [modern, legacy, module, nomodule], you provided ${mode}.`);
32 | }
33 | }
34 |
35 | apply(compiler) {
36 | compiler.hooks.compilation.tap(ID, compilation => {
37 | if (HtmlWebpackPlugin.getHooks) {
38 | HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
39 | ID,
40 | this.alterAssetTagGroups.bind(this, compiler)
41 | );
42 | } else {
43 | compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(
44 | ID,
45 | this.alterAssetTagGroups.bind(this, compiler)
46 | );
47 | }
48 | });
49 | }
50 |
51 | alterAssetTagGroups(compiler, { plugin, bodyTags: body, headTags: head, ...rest }, cb) {
52 | // Older webpack compat
53 | if (!body) body = rest.body;
54 | if (!head) head = rest.head;
55 |
56 | const targetDir = compiler.options.output.path;
57 | // get stats, write to disk
58 | const htmlName = path.basename(plugin.options.filename);
59 | // Watch out for output files in sub directories
60 | const htmlPath = path.dirname(plugin.options.filename);
61 | // Make the temporairy html to store the scripts in
62 | const tempFilename = path.join(
63 | targetDir,
64 | htmlPath,
65 | `assets-${htmlName}.json`
66 | );
67 | // If this file does not exist we are in iteration 1
68 | if (!fs.existsSync(tempFilename)) {
69 | fs.mkdirpSync(path.dirname(tempFilename));
70 | // Only keep the scripts so we can't add css etc twice.
71 | const newBody = body.filter(
72 | a => a.tagName === 'script' && a.attributes
73 | );
74 | if (this.mode === 'legacy') {
75 | // Empty nomodule in legacy build
76 | newBody.forEach(a => {
77 | a.attributes.nomodule = '';
78 | });
79 | } else {
80 | // Module in the new build
81 | newBody.forEach(a => {
82 | a.attributes.type = 'module';
83 | });
84 | }
85 | // Write it!
86 | fs.writeFileSync(tempFilename, JSON.stringify(newBody));
87 | // Tell the compiler to continue.
88 | return cb();
89 | }
90 |
91 | if (this.mode === 'modern') {
92 | // If we are in modern make the type a module.
93 | body.forEach(tag => {
94 | if (tag.tagName === 'script' && tag.attributes) {
95 | tag.attributes.type = 'module';
96 | }
97 | });
98 | } else {
99 | // If we are in legacy fill nomodule.
100 | body.forEach(tag => {
101 | if (tag.tagName === 'script' && tag.attributes) {
102 | tag.attributes.nomodule = '';
103 | }
104 | });
105 | }
106 |
107 | // Draw the existing html because we are in iteration 2.
108 | const existingAssets = JSON.parse(
109 | fs.readFileSync(tempFilename, 'utf-8')
110 | );
111 |
112 | const legacyScripts = (this.modern ? existingAssets : body).filter(tag => tag.tagName === 'script' && tag.attributes.type !== 'module');
113 | const modernScripts = (this.modern ? body : existingAssets).filter(tag => tag.tagName === 'script' && tag.attributes.type === 'module');
114 | const scripts = body.filter(tag => tag.tagName === 'script');
115 | scripts.forEach(s => {
116 | body.splice(body.indexOf(s), 1);
117 | })
118 |
119 | modernScripts.forEach(modernScript => {
120 | head.push({ tagName: 'link', attributes: { rel: 'modulepreload', href: modernScript.attributes.src } });
121 | })
122 | const loadScript = makeLoadScript(modernScripts, legacyScripts);
123 | head.push({ tagName: 'script', attributes: { type: 'module' }, innerHTML: selfScript, voidTag: false });
124 | head.push({ tagName: 'script', innerHTML: loadScript, voidTag: false });
125 |
126 | fs.removeSync(tempFilename);
127 | cb();
128 | }
129 | }
130 |
131 | module.exports = HtmlWebpackEsmodulesPlugin;
132 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | export default async function app() {
2 | return new Promise((resolve) => {
3 | setTimeout(() => {
4 | resolve('October');
5 | }, 1000);
6 | });
7 | }
8 |
--------------------------------------------------------------------------------
/src/Form.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Field, Form } from 'hooked-form';
3 |
4 | const StringField = ({ ...props }) =>
5 |
6 | const FormContainer = () => {
7 | return (
8 |
9 |
10 |
11 |
12 | )
13 | }
14 |
15 | export default Form({
16 | onSubmit: console.warn,
17 | mapPropsToValues: () => ({
18 | name: 'Jovi',
19 | place: 'Belgium',
20 | }),
21 | })(FormContainer);
22 |
--------------------------------------------------------------------------------
/src/Main.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Form from './Form';
3 |
4 | const Application = () => {
5 | const data = ['1', '2', '3']
6 | return (
7 |
8 |
9 | I am an application stating some data
10 | {data.map((x) => `${x}\n`)}
11 |
12 |
13 | Application
14 |
15 |
16 |
17 | )
18 | }
19 |
20 | export default Application;
21 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // I don't know why these aren't autopolyfilled but hey
2 | // They are excluded in modern mode so whatever floats this boat!
3 | import 'core-js/stable/object/assign'
4 | import 'core-js/features/promise';
5 | import 'preact/debug';
6 | import React from 'react';
7 | import ReactDOM from 'react-dom'
8 | import app from './App';
9 | import Application from './Main';
10 |
11 |
12 | const x = Object.assign({}, { use: true });
13 | console.log(x);
14 |
15 | async function initialize() {
16 | const result = await app();
17 | console.log(result)
18 | console.log(fetch);
19 | console.log(await fetch('https://jsonplaceholder.typicode.com/todos/1'))
20 | }
21 |
22 | initialize();
23 |
24 | // Render React app in the root element
25 | const rootEl = document.getElementById('root');
26 | if (rootEl) {
27 | ReactDOM.render(, rootEl);
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const modules = require('webpack-modules');
5 | const TerserPlugin = require('terser-webpack-plugin');
6 | // const HtmlWebpackEsmodulesPlugin = require('webpack-module-nomodule-plugin');
7 | // const ModernResolutionPlugin = require('webpack-syntax-resolver-plugin');
8 | const ModernResolutionPlugin = require('./scripts/webpack-modern-resolution-plugin');
9 | const HtmlWebpackEsmodulesPlugin = require('./scripts/webpack-module-nomodule-plugin');
10 | const babelConfig = require('./.babelrc');
11 |
12 | const env = babelConfig.env;
13 |
14 | const modernTerser = new TerserPlugin({
15 | cache: true,
16 | parallel: true,
17 | sourceMap: true,
18 | terserOptions: {
19 | ecma: 8,
20 | safari10: true
21 | }
22 | });
23 |
24 | function makeConfig(mode) {
25 | const { NODE_ENV } = process.env;
26 | const isProduction = NODE_ENV === 'production';
27 | // Build plugins
28 | const plugins = [new modules()];
29 |
30 | // multiple builds in production
31 | if (isProduction) {
32 | plugins.push(new HtmlWebpackEsmodulesPlugin(mode))
33 | }
34 |
35 | if (!isProduction) {
36 | plugins.push(new webpack.HotModuleReplacementPlugin())
37 | }
38 | // Return configuration
39 | return {
40 | mode: process.env.NODE_ENV || 'development',
41 | devtool: 'none',
42 | entry: mode === 'legacy' ? {
43 | fetch: 'whatwg-fetch',
44 | main: './src/index.js',
45 | } : {
46 | main: './src/index.js'
47 | },
48 | context: path.resolve(__dirname, './'),
49 | devServer: {
50 | contentBase: path.join(__dirname, 'dist'),
51 | host: 'localhost',
52 | port: 8080,
53 | historyApiFallback: true,
54 | hot: true,
55 | inline: true,
56 | publicPath: '/',
57 | clientLogLevel: 'none',
58 | open: true,
59 | overlay: true,
60 | },
61 | stats: 'normal',
62 | output: {
63 | chunkFilename: `[name]-[contenthash]${mode === 'modern' ? '.modern.js' : '.js'}`,
64 | filename: isProduction ? `[name]-[contenthash]${mode === 'modern' ? '.modern.js' : '.js'}` : `[name]${mode === 'modern' ? '.modern.js' : '.js'}`,
65 | path: path.resolve(__dirname, './dist'),
66 | publicPath: '/',
67 | },
68 | optimization: {
69 | splitChunks: { chunks: 'initial' },
70 | minimizer: mode === 'legacy' ? undefined : [modernTerser],
71 | },
72 | plugins: [
73 | new HtmlWebpackPlugin({ inject: true, template: './index.html' }),
74 | ...plugins
75 | ].filter(Boolean),
76 | module: {
77 | rules: [
78 | {
79 | // Support preact.
80 | test: /\.mjs$/,
81 | include: /node_modules/,
82 | type: 'javascript/auto',
83 | },
84 | {
85 | test: /\.js/,
86 | include: [
87 | path.resolve(__dirname, "src"),
88 | ],
89 | loader: 'babel-loader',
90 | options: {
91 | cacheDirectory: true,
92 | ...env[mode],
93 | }
94 | },
95 | ],
96 | },
97 | resolve: {
98 | mainFields: ['module', 'main', 'browser'],
99 | alias: {
100 | react: 'preact/compat',
101 | 'react-dom': 'preact/compat',
102 | "preact": path.resolve(__dirname, 'node_modules', 'preact'),
103 | ...(mode === 'modern' ? { 'url': 'native-url' } : {})
104 | },
105 | plugins: mode === 'modern' ? [new ModernResolutionPlugin()] : undefined,
106 | },
107 | };
108 | };
109 |
110 | module.exports = process.env.NODE_ENV === 'production' ?
111 | [makeConfig('modern'), makeConfig('legacy')] :
112 | makeConfig('legacy');
113 |
--------------------------------------------------------------------------------