├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── contributing.md ├── lib ├── index.js ├── reshape_replace_plugin.js └── rewrite_assets_plugin.js ├── package.json ├── test ├── fixtures │ └── basic │ │ ├── index.html │ │ ├── index.js │ │ └── secondary.js └── index.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .nyc_output 4 | coverage 5 | test/fixtures/*/public 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | contributing.md 3 | .editorconfig 4 | coverage 5 | .nyc_output 6 | yarn.lock 7 | .travis.yml 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 7 5 | after_script: 6 | - npm run codecov 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | License (MIT) 2 | ------------- 3 | 4 | Copyright © 2017 static-dev 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spike Optimization Plugin 2 | 3 | [](https://npmjs.com/package/spike-optimize) 4 | [](https://travis-ci.org/static-dev/spike-optimize?branch=master) 5 | [](https://david-dm.org/static-dev/spike-optimize) 6 | [](https://codecov.io/gh/static-dev/spike-optimize) 7 | 8 | A plugin that provides bleeding edge performance optimizations for spike. 9 | 10 | > **Note:** This project is in early development, and versioning is a little different. [Read this](http://markup.im/#q4_cRZ1Q) for more details. 11 | 12 | ### Installation 13 | 14 | `npm install spike-optimize -S` 15 | 16 | ### Usage 17 | 18 | Currently, spike-optimize provides a simple interface through which you can enable scope hoisting, aggressive splitting for http/2, and hashed asset processing -- features that are on the more advanced side of webpack configuration, but which can be set up with simple boolean options through this plugin. For example: 19 | 20 | ```js 21 | const optimize = require('spike-optimize') 22 | 23 | module.exports = { 24 | // ... your config ... 25 | afterSpikePlugins: [ 26 | ...optimize({ 27 | scopeHoisting: true, 28 | minify: true, 29 | aggressiveSplitting: true // or set your size limits ex. [30000, 50000] 30 | }) 31 | ] 32 | } 33 | ``` 34 | 35 | > NOTE: Notice that optimize actually returns an array of plugins, so the instantiation is slightly different here. 36 | 37 | Out of the box, this plugin will force webpack to use [chunkhash naming](https://medium.com/@okonetchnikov/long-term-caching-of-static-assets-with-webpack-1ecb139adb95) optimized for long term cacheing. Now, as soon as you are using hash naming, you no longer have a way to include the scripts on your page, since they are named randomly. However, this plugin will scan your pages and detect when you are using the assets as they are named in your entries automatically, then replace them with the correct path. So you can still include the script like this: 38 | 39 | ```html 40 |
41 | 42 | 43 | 44 | ``` 45 | 46 | Any scripts that webpack processes will be transformed such that the naming is correct if you are using hash naming, and if it has been split into multiple outputs, it will be replaced by multiple script tags. So for example, if you have `aggressiveSplitting` set to `true`, your result might look like this: 47 | 48 | ```html 49 | 50 | 51 | 52 | 53 | 54 | 55 | ``` 56 | 57 | In this case, your bundle has been split into a couple files to optimize http/2 load speed, and each file has been renamed to a hash. If you just use the plugin with default settings, it won't split your output and it will look more like this: 58 | 59 | ```html 60 | 61 | 62 | 63 | 64 | ``` 65 | 66 | Note that at the moment, these optimizations are only available for javascript, not css. Aggressive splitting and hash naming could be marginally useful for css, and I will consider adding this in the future. In the meantime, PRs accepted! 67 | 68 | Also note that this plugin will significantly slow down compilation due to the extra optimizations, and is only recommended to be used in production (but please test it locally before going live). 69 | 70 | ### Options 71 | 72 | | Name | Description | Default | 73 | | ---- | ----------- | ------- | 74 | | **scopeHoisting** | Configures webpack to use [scope hoisting](https://medium.com/webpack/brief-introduction-to-scope-hoisting-in-webpack-8435084c171f). | | 75 | | **aggressiveSplitting** | Configures webpack to use [aggressive splitting](https://medium.com/webpack/webpack-http-2-7083ec3f3ce6) for optimized h2. | | 76 | | **minify** | Activates the uglifyjs plugin for minifying your js | | 77 | 78 | ### License & Contributing 79 | 80 | - Details on the license [can be found here](LICENSE.md) 81 | - Details on running tests and contributing [can be found here](contributing.md) 82 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to spike-optimize 2 | 3 | Hello there! First of all, thanks for being interested in spike-optimize and helping out. We all think you are awesome, and by contributing to open source projects, you are making the world a better place. That being said, there are a few ways to make the process of contributing code to spike-optimize smoother, detailed below: 4 | 5 | ### Filing Issues 6 | 7 | If you are opening an issue about a bug, make sure that you include clear steps for how we can reproduce the problem. _If we can't reproduce it, we can't fix it_. If you are suggesting a feature, make sure your explanation is clear and detailed. 8 | 9 | ### Getting Set Up 10 | 11 | - Clone the project down 12 | - Make sure [nodejs](http://nodejs.org) has been installed and is above version `6.x` 13 | - Run `npm install` 14 | - Put in work 15 | 16 | ### Testing 17 | 18 | This project is constantly evolving, and to ensure that things are secure and working for everyone, we need to have tests. If you are adding a new feature, please make sure to add a test for it. The test suite for this project uses [ava](https://github.com/sindresorhus/ava). 19 | 20 | To run the test suite just use `npm test` or install ava globally and use the `ava` command to run the tests. 21 | 22 | ### Code Style 23 | 24 | This project uses ES6, interpreted directly by node.js. To keep a consistent coding style in the project, we are using [standard js](http://standardjs.com/). In order for tests to pass, all code must pass standard js linting. This project also uses an [editorconfig](http://editorconfig.org/). It will make life much easier if you have the [editorconfig plugin](http://editorconfig.org/#download) for your text editor. For any inline documentation in the code, we're using [JSDoc](http://usejsdoc.org/). 25 | 26 | ### Commit Cleanliness 27 | 28 | It's ok if you start out with a bunch of experimentation and your commit log isn't totally clean, but before any pull requests are accepted, we like to have a nice clean commit log. That means [well-written and clear commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) and commits that each do something significant, rather than being typo or bug fixes. 29 | 30 | If you submit a pull request that doesn't have a clean commit log, we will ask you to clean it up before we accept. This means being familiar with rebasing - if you are not, [this guide](https://help.github.com/articles/interactive-rebase) by github should help you to get started. And if you are still confused, feel free to ask! 31 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const RewriteAssetsPlugin = require('./rewrite_assets_plugin') 3 | 4 | module.exports = function spikeOptimize (opts) { 5 | const res = [] 6 | 7 | // Add scope hosting plugin if requested 8 | if (opts.scopeHoisting) { 9 | res.push(new webpack.optimize.ModuleConcatenationPlugin()) 10 | } 11 | 12 | // Add aggressive splitting plugin if requested 13 | if (opts.aggressiveSplitting) { 14 | let minSize = 30000 15 | let maxSize = 50000 16 | if (Array.isArray(opts.aggressiveSplitting)) { 17 | minSize = opts.aggressiveSplitting[0] 18 | maxSize = opts.aggressiveSplitting[1] 19 | } 20 | res.push(new webpack.optimize.AggressiveSplittingPlugin({ minSize, maxSize })) 21 | } 22 | 23 | // Add minify plugin if requested 24 | if (opts.minify) { 25 | res.push(new webpack.optimize.UglifyJsPlugin()) 26 | } 27 | 28 | // Always add commons chunk and rewrite assets plugin 29 | res.push(new webpack.optimize.CommonsChunkPlugin({ names: ['manifest'] })) 30 | res.push(new RewriteAssetsPlugin()) 31 | 32 | return res 33 | } 34 | -------------------------------------------------------------------------------- /lib/reshape_replace_plugin.js: -------------------------------------------------------------------------------- 1 | const util = require('reshape-plugin-util') 2 | 3 | module.exports = function reshapeReplaceAssets (entryChunks, manifest) { 4 | return function replaceAssetsPlugin (tree) { 5 | let manifestAdded = false 6 | 7 | return util.modifyNodes(tree, (node) => { 8 | return node.type === 'tag' && node.name === 'script' 9 | }, (node) => { 10 | if (!node.attrs || !node.attrs.src) return node 11 | 12 | // Check if the script src matches a webpack entry name 13 | // TODO: handle multi nodes 14 | const scriptPath = node.attrs.src[0].content 15 | const webpackOutputs = Object.keys(entryChunks).map((e) => `${e}.js`) 16 | const match = webpackOutputs.filter((out) => { 17 | return scriptPath.match(new RegExp(`${out}$`)) 18 | })[0] 19 | 20 | // If there's no match we don't need to do any processing 21 | if (!match) { return node } 22 | 23 | // If it does match, replace it with webpack-processed outputs 24 | const entryKey = match.replace(/\.js$/, '') 25 | const replacementChunks = entryChunks[entryKey] 26 | const res = [] 27 | if (!manifestAdded) { 28 | res.push({ 29 | type: 'tag', 30 | name: 'script', 31 | location: node.location, 32 | content: [{ 33 | type: 'text', 34 | content: manifest 35 | }] 36 | }) 37 | manifestAdded = true 38 | } 39 | res.push(...replacementChunks.map((id) => { 40 | const newNode = deepClone(node) 41 | newNode.attrs.src[0].content = newNode.attrs.src[0].content.replace(new RegExp(`${match}$`), `${id}.js`) 42 | return newNode 43 | })) 44 | return res 45 | }) 46 | } 47 | } 48 | 49 | function deepClone (obj) { 50 | return JSON.parse(JSON.stringify(obj)) 51 | } 52 | -------------------------------------------------------------------------------- /lib/rewrite_assets_plugin.js: -------------------------------------------------------------------------------- 1 | const Util = require('spike-util') 2 | const reshape = require('reshape') 3 | const reshapeReplacePlugin = require('./reshape_replace_plugin') 4 | const {filter, map} = require('objectfn') 5 | 6 | module.exports = class RewriteAssetsPlugin { 7 | apply (compiler) { 8 | this.util = new Util(compiler.options) 9 | // Switch output naming to use a hash for long term cacheing 10 | compiler.options.output.filename = '[name].[chunkhash].js' 11 | 12 | compiler.plugin('emit', (compilation, done) => { 13 | // gets the chunk ids that are part of each entrypoint (minus manifest) 14 | let manifestName 15 | const entryChunks = Object.keys(compiler.options.entry).reduce((m, e) => { 16 | m[e] = compilation.entrypoints[e].chunks 17 | .filter((c) => { 18 | if (c.name === 'manifest') { 19 | manifestName = `${c.name || c.id}.${c.renderedHash || c.hash}.js` 20 | return false 21 | } else { 22 | return true 23 | } 24 | }) 25 | // https://github.com/webpack/webpack/blob/ab2270263e9af5b0dd83e454c20ab3aa1797424a/lib/TemplatedPathPlugin.js#L56 26 | // TODO: use this exact function instead, if we can 27 | .map((c) => `${c.name || c.id}.${c.renderedHash || c.hash}`) 28 | return m 29 | }, {}) 30 | 31 | // get the webpack manifest source (this will be inlined) 32 | const manifest = compilation.assets[manifestName].source() 33 | 34 | // get the sources of all the html pages to be scanned for replacement 35 | const htmlOutputs = filter(compilation.assets, (v, k) => { 36 | return k.match(/\.html$/) 37 | }) 38 | 39 | const sources = map(htmlOutputs, (v) => String(v.source())) 40 | 41 | // run reshape on each html file to scan for asset includes that need 42 | // to be replaced, and replace any script paths coming from webpack 43 | promiseMapObj(sources, (k, src) => { 44 | return reshape({ 45 | plugins: [reshapeReplacePlugin(entryChunks, manifest)] 46 | }).process(src) 47 | .then((res) => { 48 | // replace old asset with the processed/replaced version 49 | const html = res.output() 50 | compilation.assets[k] = { 51 | source: () => html, 52 | size: () => html.length 53 | } 54 | }) 55 | }).then(() => { 56 | // remove the manifest standalone, since we inline it 57 | delete compilation.assets[manifestName] 58 | // that's all! 59 | done() 60 | }).catch(done) 61 | }) 62 | } 63 | } 64 | 65 | // Small utility for mapping through an object with promise values 66 | function promiseMapObj (obj, cb) { 67 | const promises = [] 68 | const results = {} 69 | map(obj, (v, k) => { 70 | promises.push(cb(k, v).then((res) => { results[k] = res })) 71 | }) 72 | return Promise.all(promises).then(() => results) 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spike-optimize", 3 | "description": "bleeding edge performance optimization for spike", 4 | "version": "0.1.2", 5 | "author": "static-dev", 6 | "ava": { 7 | "verbose": "true", 8 | "serial": "true" 9 | }, 10 | "bugs": "https://github.com/static-dev/spike-optimize/issues", 11 | "dependencies": { 12 | "objectfn": "^2.0.0", 13 | "reshape": "^0.5.0", 14 | "reshape-plugin-util": "^0.2.1", 15 | "rimraf-promise": "^2.0.0", 16 | "spike-util": "^1.3.0" 17 | }, 18 | "devDependencies": { 19 | "ava": "^0.25.0", 20 | "codecov": "^3.0.2", 21 | "nyc": "^12.0.2", 22 | "react": "^16.4.0", 23 | "snazzy": "^7.1.1", 24 | "spike-core": "^2.2.0", 25 | "standard": "^11.0.1" 26 | }, 27 | "engines": { 28 | "node": ">= 6" 29 | }, 30 | "homepage": "https://github.com/static-dev/spike-optimize", 31 | "keywords": [ 32 | "spikeplugin" 33 | ], 34 | "license": "MIT", 35 | "main": "lib", 36 | "repository": "static-dev/spike-optimize", 37 | "scripts": { 38 | "codecov": "nyc report -r lcovonly && codecov", 39 | "coverage": "nyc ava && nyc report --reporter=html && open ./coverage/index.html", 40 | "lint": "standard | snazzy", 41 | "test": "nyc ava" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |this is the content, what a great content
8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.js: -------------------------------------------------------------------------------- 1 | import 'react' 2 | -------------------------------------------------------------------------------- /test/fixtures/basic/secondary.js: -------------------------------------------------------------------------------- 1 | import 'react' 2 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const optimize = require('..') 2 | const Spike = require('spike-core') 3 | const test = require('ava') 4 | const path = require('path') 5 | const fs = require('fs') 6 | const rimraf = require('rimraf-promise') 7 | const fixtures = path.join(__dirname, 'fixtures') 8 | 9 | test('full functionality', (t) => { 10 | return compileProject('basic', { 11 | entry: { main: './index.js', second: './secondary.js' }, 12 | afterSpikePlugins: [...optimize({ 13 | scopeHoisting: true, 14 | aggressiveSplitting: true, 15 | minify: true 16 | })] 17 | }).then(({ publicPath }) => { 18 | const outputFiles = fs.readdirSync(publicPath).filter((n) => n !== 'index.html') 19 | const html = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 20 | outputFiles.map((f) => { t.regex(html, new RegExp(f)) }) 21 | return rimraf(publicPath) 22 | }) 23 | }) 24 | 25 | test('only scope hoisting option', (t) => { 26 | return compileProject('basic', { 27 | entry: { main: './index.js' }, 28 | afterSpikePlugins: [...optimize({ scopeHoisting: true })] 29 | }).then(({ publicPath, stats }) => { 30 | const filenames = Object.keys(stats.compilation.assets) 31 | t.is(filenames.length, 2) 32 | t.regex(filenames[0], /main\..*\.js/) 33 | const index = stats.compilation.assets['index.html'].source() 34 | t.regex(index, /\/\/ webpackBootstrap/) 35 | t.regex(index, /script src="\/main\..*\.js"/) 36 | return rimraf(publicPath) 37 | }) 38 | }) 39 | 40 | test('only aggressive splitting option', (t) => { 41 | return compileProject('basic', { 42 | entry: { main: './index.js' }, 43 | afterSpikePlugins: [...optimize({ aggressiveSplitting: true })] 44 | }).then(({ publicPath, stats }) => { 45 | const filenames = Object.keys(stats.compilation.assets) 46 | t.is(filenames.length, 4) 47 | const index = stats.compilation.assets['index.html'].source() 48 | t.regex(index, /\/\/ webpackBootstrap/) 49 | t.regex(index, /script src="\/0\..*\.js"/) 50 | t.regex(index, /script src="\/1\..*\.js"/) 51 | t.regex(index, /script src="\/2\..*\.js"/) 52 | return rimraf(publicPath) 53 | }) 54 | }) 55 | 56 | test('aggressive splitting size params', (t) => { 57 | return compileProject('basic', { 58 | entry: { main: './index.js' }, 59 | afterSpikePlugins: [...optimize({ aggressiveSplitting: [5000, 10000] })] 60 | }).then(({ publicPath, stats }) => { 61 | const filenames = Object.keys(stats.compilation.assets) 62 | t.is(filenames.length, 14) 63 | return rimraf(publicPath) 64 | }) 65 | }) 66 | 67 | test('only minify option', (t) => { 68 | return compileProject('basic', { 69 | entry: { main: './index.js' }, 70 | afterSpikePlugins: [...optimize({ minify: true })] 71 | }).then(({ publicPath, stats }) => { 72 | const filenames = Object.keys(stats.compilation.assets) 73 | t.is(filenames.length, 2) 74 | const index = stats.compilation.assets['index.html'].source() 75 | t.regex(index, /window\.webpackJsonp/) 76 | t.regex(index, /script src="\/main\..*\.js"/) 77 | return rimraf(publicPath) 78 | }) 79 | }) 80 | 81 | // utility function 82 | function compileProject (name, config) { 83 | return new Promise((resolve, reject) => { 84 | const root = path.join(fixtures, name) 85 | const project = new Spike(Object.assign({ root }, config)) 86 | 87 | project.on('error', reject) 88 | project.on('warning', reject) 89 | project.on('compile', (res) => { 90 | resolve({ publicPath: path.join(root, 'public'), stats: res.stats }) 91 | }) 92 | 93 | project.compile() 94 | }) 95 | } 96 | --------------------------------------------------------------------------------