├── .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 | --------------------------------------------------------------------------------