├── .babelrc ├── .circleci └── config.yml ├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── AUTHORS.md ├── LICENSE ├── README.md ├── example ├── client.jsx ├── components │ ├── App.jsx │ ├── Content.jsx │ ├── ContentNested.jsx │ ├── Header.jsx │ ├── Loading.js │ └── multilevel │ │ ├── Multilevel.jsx │ │ ├── SharedMultilevel.jsx │ │ └── level-1 │ │ └── level-2 │ │ └── DeepLevel.jsx └── server.js ├── package-lock.json ├── package.json ├── source ├── ReactLoadableSSRAddon.js ├── ReactLoadableSSRAddon.test.js ├── getBundles.js ├── getBundles.test.js ├── index.js └── utils │ ├── computeIntegrity.js │ ├── getFileExtension.js │ ├── getFileExtension.test.js │ ├── hasEntry.js │ ├── hasEntry.test.js │ ├── index.js │ ├── unique.js │ └── unique.test.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "presets": [ 4 | ["@babel/preset-env", { 5 | "loose": true 6 | }], 7 | "@babel/preset-react" 8 | ], 9 | "plugins": [ 10 | "@babel/plugin-transform-runtime", 11 | "@babel/plugin-proposal-class-properties", 12 | "@babel/plugin-transform-object-assign", 13 | "dynamic-import-node", 14 | "react-loadable/babel", 15 | ["module-resolver", { 16 | "alias": { 17 | "react-loadable-ssr-addon": "./source/index.js" 18 | } 19 | }] 20 | ], 21 | "env": { 22 | "production": { 23 | "plugins": [ 24 | ["transform-remove-console", { 25 | "exclude": [ "error", "warn"] 26 | }] 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | docker: 5 | - image: circleci/node:8.9.1 6 | 7 | jobs: 8 | test: 9 | <<: *defaults 10 | steps: 11 | - checkout 12 | 13 | - restore_cache: 14 | keys: 15 | - react-loadable-ssr-addon-{{ checksum "package.json" }} 16 | # fallback to using the latest cache if no exact match is found 17 | - react-loadable-ssr-addon- 18 | 19 | - run: npm install 20 | - run: 21 | name: Run Tests 22 | command: npm run test 23 | 24 | - save_cache: 25 | paths: 26 | - node_modules 27 | key: react-loadable-ssr-addon-{{ checksum "package.json" }} 28 | 29 | - persist_to_workspace: 30 | root: . 31 | paths: . 32 | 33 | publish: 34 | <<: *defaults 35 | steps: 36 | - attach_workspace: 37 | at: . 38 | - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ./.npmrc 39 | - run: npm publish 40 | 41 | workflows: 42 | version: 2 43 | main: 44 | jobs: 45 | - test: 46 | filters: 47 | tags: 48 | only: /^v.*/ 49 | - publish: 50 | requires: 51 | - test 52 | filters: 53 | tags: 54 | only: /^v.*/ 55 | branches: 56 | ignore: /.*/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "no-console": ["error", { "allow": ["warn", "error"] }] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@themgoncalves.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Issue Title 2 | 3 | 4 | ## Expected Behavior 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | ## Possible Solution 11 | 12 | 13 | ## Steps to Reproduce 14 | 15 | 16 | 1. 17 | 2. 18 | 3. 19 | 4. 20 | 21 | ## Context (Environment) 22 | 23 | 24 | 25 | 26 | 27 | ### Environment 28 | 29 | 1. OS running: 30 | 2. Node version: 31 | 3. Webpack version: 32 | 33 | ## Detailed Description 34 | 35 | 36 | ## Possible Implementation 37 | 38 | 39 | ## Other Comments 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | A few sentences describing the overall goals of the pull request's commits. 3 | 4 | ## Why 5 | A few sentences describing the reasons behind this pull request. 6 | 7 | ## Checklist 8 | 9 | - [ ] Your code builds clean without any `errors` or `warnings` 10 | - [ ] You are using `approved terminology` 11 | - [ ] You have added `unit tests`, if apply. 12 | 13 | ## Emojis for categorizing pull requests: 14 | 15 | ⚡️ New feature (`:zap:`) 16 | 🐛 Bug fix (`:bug:`) 17 | 🔥 P0 fix (`:fire:`) 18 | ✅ Tests (`:white_check_mark:`) 19 | 🚀 Performance improvements (`:rocket:`) 20 | 🖍 CSS / Styling (`:crayon:`) 21 | ♿ Accessibility (`:wheelchair:`) 22 | 🌐 Internationalization (`:globe_with_meridians:`) 23 | 📖 Documentation (`:book:`) 24 | 🏗 Infrastructure / Tooling / Builds / CI (`:building_construction:`) 25 | ⏪ Reverting a previous change (`:rewind:`) 26 | ♻️ Refactoring (like moving around code w/o any changes) (`:recycle:`) 27 | 🚮 Deleting code (`:put_litter_in_its_place:`) 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | yarn-error.log 3 | .idea/ 4 | node_modules/ 5 | lib/ 6 | example/dist/ 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .idea 3 | .eslintrc 4 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | react-loadable-ssr-addon is authored by: 2 | 3 | * Alexey Pyltsyn 4 | * Endi 5 | * Jérémie Parker 6 | * Marcos 7 | * Marcos Gonçalves 8 | * Ngoc Phuong 9 | * Phuong Nguyen 10 | * Reece Dunham 11 | * Sébastien Lorber 12 | * Troy Rhinehart <81650390+trhinehart-godaddy@users.noreply.github.com> 13 | * dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 14 | * endiliey 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Marcos Gonçalves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Loadable SSR Add-on 2 | > Server Side Render add-on for React Loadable. Load splitted chunks was never that easy. 3 | 4 | [![NPM][npm-image]][npm-url] 5 | [![CircleCI][circleci-image]][circleci-url] 6 | [![GitHub All Releases][releases-image]][releases-url] 7 | [![GitHub stars][stars-image]][stars-url] 8 | [![Known Vulnerabilities][vulnerabilities-image]][vulnerabilities-url] 9 | [![GitHub issues][issues-image]][issues-url] 10 | [![Awesome][awesome-image]][awesome-url] 11 | 12 | ## Description 13 | 14 | `React Loadable SSR Add-on` is a `server side render` add-on for [React Loadable](https://github.com/jamiebuilds/react-loadable) 15 | that helps you to load dynamically all files dependencies, e.g. `splitted chunks`, `css`, etc. 16 | 17 | Oh yeah, and we also **provide support for [SRI (Subresource Integrity)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)**. 18 | 19 |
20 | 21 | ## Installation 22 | 23 | **Download our NPM Package** 24 | 25 | ```sh 26 | npm install react-loadable-ssr-addon 27 | # or 28 | yarn add react-loadable-ssr-addon 29 | ``` 30 | 31 | **Note**: `react-loadable-ssr-addon` **should not** be listed in the `devDependencies`. 32 | 33 |
34 | 35 | ## How to use 36 | 37 | ### 1 - Webpack Plugin 38 | 39 | First we need to import the package into our component; 40 | 41 | ```javascript 42 | const ReactLoadableSSRAddon = require('react-loadable-ssr-addon'); 43 | 44 | module.exports = { 45 | entry: { 46 | // ... 47 | }, 48 | output: { 49 | // ... 50 | }, 51 | module: { 52 | // ... 53 | }, 54 | plugins: [ 55 | new ReactLoadableSSRAddon({ 56 | filename: 'assets-manifest.json', 57 | }), 58 | ], 59 | }; 60 | ``` 61 | 62 |
63 | 64 | ### 2 - On the Server 65 | 66 | 67 | ```js 68 | // import `getBundles` to map required modules and its dependencies 69 | import { getBundles } from 'react-loadable-ssr-addon'; 70 | // then import the assets manifest file generated by the Webpack Plugin 71 | import manifest from './your-output-folder/assets-manifest.json'; 72 | 73 | ... 74 | 75 | // react-loadable ssr implementation 76 | const modules = new Set(); 77 | 78 | const html = ReactDOMServer.renderToString( 79 | modules.add(moduleName)}> 80 | 81 | 82 | ); 83 | 84 | ... 85 | 86 | // now we concatenate the loaded `modules` from react-loadable `Loadable.Capture` method 87 | // with our application entry point 88 | const modulesToBeLoaded = [...manifest.entrypoints, ...Array.from(modules)]; 89 | // also if you find your project still fetching the files after the placement 90 | // maybe a good idea to switch the order from the implementation above to 91 | // const modulesToBeLoaded = [...Array.from(modules), ...manifest.entrypoints]; 92 | // see the issue #6 regarding this thread 93 | // https://github.com/themgoncalves/react-loadable-ssr-addon/issues/6 94 | 95 | // after that, we pass the required modules to `getBundles` map it. 96 | // `getBundles` will return all the required assets, group by `file type`. 97 | const bundles = getBundles(manifest, modulesToBeLoaded); 98 | 99 | // so it's easy to implement it 100 | const styles = bundles.css || []; 101 | const scripts = bundles.js || []; 102 | 103 | res.send(` 104 | 105 | 106 | ... 107 | ${styles.map(style => { 108 | return ``; 109 | }).join('\n')} 110 | 111 |
${html}
112 | ${scripts.map(script => { 113 | return `` 114 | }).join('\n')} 115 | 116 | `); 117 | ``` 118 | 119 | See how easy to implement it is? 120 | 121 |
122 | 123 | ## API Documentation 124 | 125 | ### Webpack Plugin options 126 | 127 | #### `filename` 128 | 129 | Type: `string` 130 | Default: `react-loadable.json` 131 | 132 | Assets manifest file name. May contain relative or absolute path. 133 | 134 | 135 | #### `integrity` 136 | 137 | Type: `boolean` 138 | Default: `false` 139 | 140 | Enable or disable generation of [Subresource Integrity (SRI).](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hash. 141 | 142 | #### `integrityAlgorithms` 143 | 144 | Type: `array` 145 | Default: `[ 'sha256', 'sha384', 'sha512' ]` 146 | 147 | Algorithms to generate hash. 148 | 149 | 150 | #### `integrityPropertyName` 151 | 152 | Type: `string` 153 | Default: `integrity` 154 | 155 | Custom property name to be output in the assets manifest file. 156 | 157 | 158 | **Full configuration example** 159 | 160 | ```js 161 | new ReactLoadableSSRAddon({ 162 | filename: 'assets-manifest.json', 163 | integrity: false, 164 | integrityAlgorithms: [ 'sha256', 'sha384', 'sha512' ], 165 | integrityPropertyName: 'integrity', 166 | }) 167 | ``` 168 | 169 |
170 | 171 | ### Server Side 172 | 173 | ### `getBundles` 174 | 175 | ```js 176 | import { getBundles } from 'react-loadable-ssr-addon'; 177 | 178 | /** 179 | * getBundles 180 | * @param {object} manifest - The assets manifest content generate by ReactLoadableSSRAddon 181 | * @param {array} chunks - Chunks list to be loaded 182 | * @returns {array} - Assets list group by file type 183 | */ 184 | const bundles = getBundles(manifest, modules); 185 | 186 | 187 | const styles = bundles.css || []; 188 | const scripts = bundles.js || []; 189 | const xml = bundles.xml || []; 190 | const json = bundles.json || []; 191 | ... 192 | ``` 193 | 194 |
195 | 196 | ### Assets Manifest 197 | 198 | #### `Basic Structure` 199 | 200 | ```json 201 | { 202 | "entrypoints": [ ], 203 | "origins": { 204 | "app": [ ] 205 | }, 206 | "assets": { 207 | "app": { 208 | "js": [ 209 | { 210 | "file": "", 211 | "hash": "", 212 | "publicPath": "", 213 | "integrity": "" 214 | } 215 | ] 216 | } 217 | } 218 | } 219 | ``` 220 | 221 | #### `entrypoints` 222 | 223 | Type: `array` 224 | 225 | List of all application entry points defined in Webpack `entry`. 226 | 227 | 228 | #### `origins` 229 | 230 | Type: `array` 231 | 232 | Origin name requested. List all assets required for the requested origin. 233 | 234 | 235 | #### `assets` 236 | 237 | Type: `array` of objects 238 | 239 | Lists all application assets generate by Webpack, group by file type, 240 | containing an `array of objects` with the following format: 241 | 242 | ```js 243 | [file-type]: [ 244 | { 245 | "file": "", // assets file 246 | "hash": "", // file hash generated by Webpack 247 | "publicPath": "", // assets file + webpack public path 248 | "integrity": "" // integrity base64 hash, if enabled 249 | } 250 | ] 251 | ``` 252 | 253 |
254 | 255 | ### Assets Manifest Example 256 | 257 | ```json 258 | { 259 | "entrypoints": [ 260 | "app" 261 | ], 262 | "origins": { 263 | "./home": [ 264 | "home" 265 | ], 266 | "./about": [ 267 | "about" 268 | ], 269 | "app": [ 270 | "vendors", 271 | "app" 272 | ], 273 | "vendors": [ 274 | "app", 275 | "vendors" 276 | ] 277 | }, 278 | "assets": { 279 | "home": { 280 | "js": [ 281 | { 282 | "file": "home.chunk.js", 283 | "hash": "fdb00ffa16dfaf9cef0a", 284 | "publicPath": "/dist/home.chunk.js", 285 | "integrity": "sha256-Xxf7WVjPbdkJjgiZt7mvZvYv05+uErTC9RC2yCHF1RM= sha384-9OgouqlzN9KrqXVAcBzVMnlYOPxOYv/zLBOCuYtUAMoFxvmfxffbNIgendV4KXSJ sha512-oUxk3Swi0xIqvIxdWzXQIDRYlXo/V/aBqSYc+iWfsLcBftuIx12arohv852DruxKmlqtJhMv7NZp+5daSaIlnw==" 286 | } 287 | ] 288 | }, 289 | "about": { 290 | "js": [ 291 | { 292 | "file": "about.chunk.js", 293 | "hash": "7e88ef606abbb82d7e82", 294 | "publicPath": "/dist/about.chunk.js", 295 | "integrity": "sha256-ZPrPWVJRjdS4af9F1FzkqTqqSGo1jYyXNyctwTOLk9o= sha384-J1wiEV8N1foqRF7W9SEvg2s/FhQbhpKFHBTNBJR8g1yEMNRMi38y+8XmjDV/Iu7w sha512-b16+PXStO68CP52R+0ZktccMiaI1v0jOy34l/DqyGN7kEae3DpV3xPNoC8vt1WfE1kCAH7dlnHDdp1XRVhZX+g==" 296 | } 297 | ] 298 | }, 299 | "app": { 300 | "css": [ 301 | { 302 | "file": "app.css", 303 | "hash": "5888714915d8e89a8580", 304 | "publicPath": "/dist/app.css", 305 | "integrity": "sha256-3y4DyCC2cLII5sc2kaElHWhBIVMHdan/tA0akReI9qg= sha384-vCMVPKjSrrNpfnhmCD9E8SyHdfPdnM3DO/EkrbNI2vd0m2wH6BnfPja6gt43nDIF" 306 | } 307 | ], 308 | "js": [ 309 | { 310 | "file": "app.bundle.js", 311 | "hash": "0cbd05b10204597c781d", 312 | "publicPath": "/dist/app.bundle.js", 313 | "integrity": "sha256-sGdw+WVvXK1ZVQnYHI4FpecOcZtWZ99576OHCdrGil8= sha384-DZZzkPtPCTCR5UOWuGCyXQvsjyvZPoreCzqQGyrNV8+HyV9MdoYZawHX7NdGGLyi sha512-y29BlwBuwKB+BeXrrQYEBrK+mfWuOb4ok6F57kGbtrwa/Xq553Zb7lgss8RNvFjBSaMUdvXiJuhmP3HZA0jNeg==" 314 | } 315 | ] 316 | }, 317 | "vendors": { 318 | "css": [ 319 | { 320 | "file": "vendors.css", 321 | "hash": "5a9586c29103a034feb5", 322 | "publicPath": "/dist/vendors.css" 323 | } 324 | ], 325 | "js": [ 326 | { 327 | "file": "vendors.chunk.js", 328 | "hash": "5a9586c29103a034feb5", 329 | "publicPath": "/dist/vendors.chunk.js" 330 | } 331 | ] 332 | } 333 | } 334 | } 335 | ``` 336 | 337 |
338 | 339 | ## Release History 340 | * 1.0.2 341 | * FIX: [Fix support for nested `output.path`](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/30) 342 | * FIX: [Fix support for common js module](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/31) 343 | * 1.0.1 344 | * FIX: [Webpack v5 deprecation warning](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/27) 345 | * 1.0.0 346 | * BREAKING CHANGE: drop support for Webpack v3. 347 | * NEW: add [support for Webpack v5](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/26) 348 |
349 | See older release note 350 | * 0.3.0 351 | * NEW: [`@babel/runtime` become an explicit dependency](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/22) by [@RDIL](https://github.com/RDIL) 352 | > Requirement for `yarn v2`. 353 | * 0.2.3 354 | * FIX: [Parsing `null` or `undefined` to object on `getBundles()`](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/21) reported by [@slorber](https://github.com/slorber) 355 | * 0.2.2 356 | * FIX: As precaution measure, downgrade few dependencies due to node SemVer incompatibility. 357 | * 0.2.1 358 | * FIX: [Possible missing chunk](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/20) reported by [@lex111](https://github.com/lex111) 359 | * 0.2.0 360 | * Improvement: Reduce memory consumption ([Issue #17](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/17)) reported by [@endiliey](https://github.com/endiliey) 361 | * 0.1.9 362 | * FIX: [Missing entry in origins](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/13) reported by [@p-j](https://github.com/p-j); 363 | * 0.1.8 364 | * Includes all features from deprecated v0.1.7; 365 | * FIX: [Issue #11](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/11) reported by [@endiliey](https://github.com/endiliey) 366 | * ~~0.1.7 (_deprecated_)~~ 367 | * FIX: [`Cannot read property 'integrity' of undefined`](https://github.com/themgoncalves/react-loadable-ssr-addon/pull/10) reported by [@nguyenngocphuongnb](https://github.com/nguyenngocphuongnb); 368 | * Minor improvements. 369 | * 0.1.6 370 | * FIX: `getManifestOutputPath` method when requested from `Webpack Dev Middleware`; 371 | * 0.1.5 372 | * FIX: [Issue #7](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/7) reported by [@themgoncalves](https://github.com/themgoncalves) and [@tomkelsey](https://github.com/tomkelsey) 373 | * 0.1.4 374 | * FIX: [Issue #5](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/5) reported by [@tomkelsey](https://github.com/tomkelsey) 375 | * 0.1.3 376 | * FIX: [Issue #4](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/4) reported by [@tomkelsey](https://github.com/tomkelsey) 377 | * 0.1.2 378 | * FIX: [Issue #2](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/2) reported by [@tatchi](https://github.com/tatchi) 379 | * 0.1.1 380 | * FIX: [Issue #1](https://github.com/themgoncalves/react-loadable-ssr-addon/issues/1) reported by [@tatchi](https://github.com/tatchi) 381 | * 0.1.0 382 | * First release 383 | * NEW: Created `getBundles()` to retrieve required assets 384 | * NEW: Created `ReactLoadableSSRAddon` Plugin for Webpack 3+ 385 | * 0.0.1 386 | * Work in progress 387 |
388 | 389 |
390 | 391 | ## Meta 392 | 393 | ### Author 394 | **Marcos Gonçalves** – [LinkedIn](http://linkedin.com/in/themgoncalves/) – [Website](http://www.themgoncalves.com) 395 | 396 | ### License 397 | Distributed under the MIT license. [Click here](/LICENSE) for more information. 398 | 399 | [https://github.com/themgoncalves/react-loadable-ssr-addon](https://github.com/themgoncalves/react-loadable-ssr-addon) 400 | 401 | ## Contributing 402 | 403 | 1. Fork it () 404 | 2. Create your feature branch (`git checkout -b feature/fooBar`) 405 | 3. Commit your changes (`git commit -m ':zap: Add some fooBar'`) 406 | 4. Push to the branch (`git push origin feature/fooBar`) 407 | 5. Create a new Pull Request 408 | 409 | ### Emojis for categorizing commits: 410 | 411 | ⚡️ New feature (`:zap:`) 412 | 🐛 Bug fix (`:bug:`) 413 | 🔥 P0 fix (`:fire:`) 414 | ✅ Tests (`:white_check_mark:`) 415 | 🚀 Performance improvements (`:rocket:`) 416 | 🖍 CSS / Styling (`:crayon:`) 417 | ♿ Accessibility (`:wheelchair:`) 418 | 🌐 Internationalization (`:globe_with_meridians:`) 419 | 📖 Documentation (`:book:`) 420 | 🏗 Infrastructure / Tooling / Builds / CI (`:building_construction:`) 421 | ⏪ Reverting a previous change (`:rewind:`) 422 | ♻️ Refactoring (like moving around code w/o any changes) (`:recycle:`) 423 | 🚮 Deleting code (`:put_litter_in_its_place:`) 424 | 425 | 426 | 427 | [circleci-image]:https://circleci.com/gh/themgoncalves/react-loadable-ssr-addon.svg?style=svg 428 | [circleci-url]: https://circleci.com/gh/themgoncalves/react-loadable-ssr-addon 429 | [vulnerabilities-image]: https://snyk.io/test/github/themgoncalves/react-loadable-ssr-addon/badge.svg 430 | [vulnerabilities-url]: https://snyk.io/test/github/themgoncalves/react-loadable-ssr-addon 431 | [issues-image]: https://img.shields.io/github/issues/themgoncalves/react-loadable-ssr-addon.svg 432 | [issues-url]: https://github.com/themgoncalves/react-loadable-ssr-addon/issues 433 | [stars-image]: https://img.shields.io/github/stars/themgoncalves/react-loadable-ssr-addon.svg 434 | [stars-url]: https://github.com/themgoncalves/react-loadable-ssr-addon/stargazers 435 | [forks-image]: https://img.shields.io/github/forks/themgoncalves/react-loadable-ssr-addon.svg 436 | [forks-url]: https://github.com/themgoncalves/react-loadable-ssr-addon/network 437 | [releases-image]: https://img.shields.io/npm/dm/react-loadable-ssr-addon.svg 438 | [releases-url]: https://github.com/themgoncalves/react-loadable-ssr-addon 439 | [awesome-image]: https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg 440 | [awesome-url]: https://github.com/themgoncalves/react-loadable-ssr-addon 441 | [npm-image]: https://img.shields.io/npm/v/react-loadable-ssr-addon.svg 442 | [npm-url]: https://www.npmjs.com/package/react-loadable-ssr-addon 443 | -------------------------------------------------------------------------------- /example/client.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Loadable from 'react-loadable'; 4 | import App from './components/App'; 5 | 6 | window.onload = () => { 7 | Loadable.preloadReady().then(() => { 8 | ReactDOM.hydrate(, document.getElementById('app')); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /example/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from 'react-loadable'; 3 | import Loading from './Loading'; 4 | 5 | const HeaderExample = Loadable({ 6 | loader: () => import(/* webpackChunkName: "header" */'./Header'), 7 | loading: Loading, 8 | }); 9 | 10 | const ContentExample = Loadable({ 11 | loader: () => import(/* webpackChunkName: "content" */'./Content'), 12 | loading: Loading, 13 | }); 14 | 15 | const MultilevelExample = Loadable({ 16 | loader: () => import(/* webpackChunkName: "multilevel" */'./multilevel/Multilevel'), 17 | loading: Loading, 18 | }); 19 | 20 | export default function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /example/components/Content.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from "react-loadable"; 3 | import Loading from "./Loading"; 4 | 5 | const ContentNestedExample = Loadable({ 6 | loader: () => import(/* webpackChunkName: "content-nested" */'./ContentNested'), 7 | loading: Loading, 8 | }); 9 | 10 | export default function Content() { 11 | return ( 12 |
13 | Bacon ipsum dolor amet pork belly minim pork loin reprehenderit incididunt aliquip hamburger chuck culpa mollit officia nisi pig duis. 14 | Buffalo laboris duis ullamco flank. 15 | Consectetur in excepteur elit ut aute adipisicing et tongue veniam labore dolore exercitation. 16 | Swine consectetur boudin landjaeger, t-bone pork belly laborum. 17 | Bacon ex ham ribeye sirloin et venison pariatur dolor non fugiat consequat. 18 | Velit kevin non, jerky alcatra flank ball tip. 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /example/components/ContentNested.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Content() { 4 | return ( 5 | 6 |
7 | Eu prosciutto fugiat, meatloaf beef ribs jerky dolore commodo est chicken t-bone meatball capicola magna ipsum. Ribeye shankle mollit venison. 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /example/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |

React Loadable SSR Add-on

7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /example/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function Loading(props) { 4 | if (props.isLoading) { 5 | if (props.timedOut) { 6 | return
Loader timed out!
; 7 | } else if (props.pastDelay) { 8 | return
Loading...
; 9 | } 10 | return null; 11 | } else if (props.error) { 12 | return
Error! Component failed to load
; 13 | } 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /example/components/multilevel/Multilevel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from "react-loadable"; 3 | import Loading from "../Loading"; 4 | 5 | const SharedMultilevelExample = Loadable({ 6 | loader: () => import(/* webpackChunkName: "shared-multilevel" */'./SharedMultilevel'), 7 | loading: Loading, 8 | }); 9 | 10 | const DeeplevelExample = Loadable({ 11 | loader: () => import(/* webpackChunkName: "deeplevel" */'./level-1/level-2/DeepLevel'), 12 | loading: Loading, 13 | }); 14 | 15 | export default function Multilevel() { 16 | return ( 17 |
18 |
19 | Multilevel with Shared Component Example. 20 | 21 | Loading from a DeepLevel 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /example/components/multilevel/SharedMultilevel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* eslint-disable react/jsx-one-expression-per-line */ 4 | export default function SharedMultilevel() { 5 | return ( 6 |
    7 |
  • 8 | this is a shared multilevel component 9 |
  • 10 |
11 | ); 12 | } 13 | /* eslint-enable react/jsx-one-expression-per-line */ 14 | -------------------------------------------------------------------------------- /example/components/multilevel/level-1/level-2/DeepLevel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from "react-loadable"; 3 | import Loading from "../../../Loading"; 4 | 5 | const SharedMultilevelExample = Loadable({ 6 | loader: () => import(/* webpackChunkName: "shared-multilevel" */'../../SharedMultilevel'), 7 | loading: Loading, 8 | }); 9 | 10 | export default function DeepLevel() { 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | import React from 'react'; 4 | import { renderToString } from 'react-dom/server'; 5 | import Loadable from 'react-loadable'; 6 | import { getBundles } from 'react-loadable-ssr-addon'; 7 | import App from './components/App'; 8 | 9 | const manifest = require('./dist/react-loadable-ssr-addon.json'); 10 | const server = express(); 11 | 12 | server.use('/dist', express.static(path.join(__dirname, 'dist'))); 13 | 14 | server.get('*', (req, res) => { 15 | const modules = new Set(); 16 | const html = renderToString( 17 | modules.add(moduleName)}> 18 | 19 | 20 | ); 21 | 22 | const bundles = getBundles(manifest, [...manifest.entrypoints, ...Array.from(modules)]); 23 | 24 | const styles = bundles.css || []; 25 | const scripts = bundles.js || []; 26 | 27 | res.send(` 28 | 29 | 30 | 31 | 32 | 33 | 34 | React Loadable SSR Add-on Example 35 | ${styles.map(style => { 36 | return ``; 37 | }).join('\n')} 38 | 39 | 40 |
${html}
41 | ${scripts.map(script => { 42 | return `` 43 | }).join('\n')} 44 | 45 | 46 | `); 47 | }); 48 | 49 | Loadable.preloadAll().then(() => { 50 | server.listen(3003, () => { 51 | console.log('Running on http://localhost:3003/'); 52 | }); 53 | }).catch(err => { 54 | console.log(err); 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loadable-ssr-addon", 3 | "version": "1.0.2", 4 | "description": "Server Side Render add-on for React Loadable. Load splitted chunks was never that easy.", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/themgoncalves/react-loadable-ssr-addon.git" 9 | }, 10 | "keywords": [ 11 | "react", 12 | "react-loadable", 13 | "webpack", 14 | "splitted-chunks", 15 | "assets-manifest", 16 | "server-side-render", 17 | "ssr" 18 | ], 19 | "author": "Marcos Gonçalves ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/themgoncalves/react-loadable-ssr-addon/issues" 23 | }, 24 | "scripts": { 25 | "authors": "echo 'react-loadable-ssr-addon is authored by: \n' > AUTHORS.md | git log --format='* %aN <%aE>' | sort -u >> AUTHORS.md", 26 | "prepare": "npm run build && npm run authors", 27 | "prepublishOnly": "npm test && npm run lint", 28 | "preversion": "npm run lint", 29 | "postversion": "git push && git push --tags", 30 | "start": "npm run clean:example && npm run build && webpack && babel-node example/server.js", 31 | "build": "NODE_ENV=production && rm -rf lib && babel source -d lib", 32 | "clean:example": "rm -rf ./example/dist/", 33 | "lint": "eslint --ext js --ext jsx source || exit 0", 34 | "lint:fix": "eslint --ext js --ext jsx source --fix|| exit 0", 35 | "test": "npm run clean:example && npm run build && webpack && ava; npm run clean:example", 36 | "stats": "NODE_ENV=development webpack --profile --json > compilation-stats.json" 37 | }, 38 | "engines": { 39 | "node": ">=10.13.0" 40 | }, 41 | "resolutions": { 42 | "yargs-parser": "13.1.2", 43 | "mem": "4.0.0" 44 | }, 45 | "peerDependencies": { 46 | "react-loadable": "*", 47 | "webpack": ">=4.41.1 || 5.x" 48 | }, 49 | "devDependencies": { 50 | "@babel/cli": "^7.10.1", 51 | "@babel/core": "^7.10.1", 52 | "@babel/node": "^7.10.1", 53 | "@babel/plugin-proposal-class-properties": "^7.10.1", 54 | "@babel/plugin-proposal-object-rest-spread": "^7.10.1", 55 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 56 | "@babel/plugin-transform-classes": "^7.10.1", 57 | "@babel/plugin-transform-object-assign": "^7.10.1", 58 | "@babel/plugin-transform-runtime": "^7.10.1", 59 | "@babel/preset-env": "^7.10.1", 60 | "@babel/preset-react": "^7.10.1", 61 | "@babel/register": "^7.10.1", 62 | "ava": "^2.4.0", 63 | "babel-loader": "^8.1.0", 64 | "babel-plugin-dynamic-import-node": "^2.3.3", 65 | "babel-plugin-module-resolver": "^4.0.0", 66 | "babel-plugin-transform-remove-console": "^6.9.4", 67 | "babel-preset-minify": "^0.5.1", 68 | "eslint": "^6.5.1", 69 | "eslint-config-airbnb": "18.1.0", 70 | "eslint-plugin-import": "^2.20.2", 71 | "eslint-plugin-jsx-a11y": "^6.2.3", 72 | "eslint-plugin-react": "^7.20.0", 73 | "express": "^4.17.1", 74 | "husky": "^3.0.9", 75 | "react": "^16.13.1", 76 | "react-dom": "^16.13.1", 77 | "react-loadable": "^5.5.0", 78 | "wait-for-expect": "^3.0.2", 79 | "webpack": "4.44.1", 80 | "webpack-cli": "^4.5.0" 81 | }, 82 | "husky": { 83 | "hooks": { 84 | "pre-commit": "npm run lint", 85 | "pre-push": "npm run test" 86 | } 87 | }, 88 | "ava": { 89 | "files": [ 90 | "source/**/*.test.js" 91 | ], 92 | "require": [ 93 | "@babel/register" 94 | ], 95 | "concurrency": 5 96 | }, 97 | "dependencies": { 98 | "@babel/runtime": "^7.10.3" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /source/ReactLoadableSSRAddon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import url from 'url'; 10 | import { getFileExtension, computeIntegrity, hasEntry } from './utils'; 11 | 12 | // Webpack plugin name 13 | const PLUGIN_NAME = 'ReactLoadableSSRAddon'; 14 | 15 | const WEBPACK_VERSION = (function GetVersion() { 16 | try { 17 | // eslint-disable-next-line global-require 18 | return require('webpack/package.json').version; 19 | } catch (err) { 20 | return ''; 21 | } 22 | }()); 23 | 24 | const WEBPACK_5 = WEBPACK_VERSION.startsWith('5.'); 25 | 26 | // Default plugin options 27 | const defaultOptions = { 28 | filename: 'assets-manifest.json', 29 | integrity: false, 30 | integrityAlgorithms: ['sha256', 'sha384', 'sha512'], 31 | integrityPropertyName: 'integrity', 32 | }; 33 | 34 | /** 35 | * React Loadable SSR Add-on for Webpack 36 | * @class ReactLoadableSSRAddon 37 | * @desc Generate application assets manifest with its dependencies. 38 | */ 39 | class ReactLoadableSSRAddon { 40 | /** 41 | * @constructs ReactLoadableSSRAddon 42 | * @param options 43 | */ 44 | constructor(options = defaultOptions) { 45 | this.options = { ...defaultOptions, ...options }; 46 | this.compiler = null; 47 | this.stats = null; 48 | this.entrypoints = new Set(); 49 | this.assetsByName = new Map(); 50 | this.manifest = {}; 51 | } 52 | 53 | /** 54 | * Check if request is from Dev Server 55 | * aka webpack-dev-server 56 | * @method isRequestFromDevServer 57 | * @returns {boolean} - True or False 58 | */ 59 | get isRequestFromDevServer() { 60 | if (process.argv.some((arg) => arg.includes('webpack-dev-server'))) { return true; } 61 | 62 | const { outputFileSystem, outputFileSystem: { constructor: { name } } } = this.compiler; 63 | 64 | return outputFileSystem && name === 'MemoryFileSystem'; 65 | } 66 | 67 | /** 68 | * Get assets manifest output path 69 | * @readonly 70 | * @method manifestOutputPath 71 | * @returns {string} - Output path containing path + filename. 72 | */ 73 | get manifestOutputPath() { 74 | const { filename } = this.options; 75 | if (path.isAbsolute(filename)) { 76 | return filename; 77 | } 78 | 79 | const { outputPath, options: { devServer } } = this.compiler; 80 | 81 | if (this.isRequestFromDevServer && devServer) { 82 | let devOutputPath = (devServer.outputPath || outputPath || '/'); 83 | 84 | if (devOutputPath === '/') { 85 | console.warn('Please use an absolute path in options.output when using webpack-dev-server.'); 86 | devOutputPath = this.compiler.context || process.cwd(); 87 | } 88 | 89 | return path.resolve(devOutputPath, filename); 90 | } 91 | 92 | return path.resolve(outputPath, filename); 93 | } 94 | 95 | /** 96 | * Get application assets chunks 97 | * @method getAssets 98 | * @param {array} assetsChunk - Webpack application chunks 99 | * @returns {Map} 100 | */ 101 | getAssets(assetsChunk) { 102 | for (let i = 0; i < assetsChunk.length; i += 1) { 103 | const chunk = assetsChunk[i]; 104 | const { 105 | id, files, siblings = [], hash, 106 | } = chunk; 107 | 108 | const keys = this.getChunkOrigin(chunk); 109 | 110 | for (let j = 0; j < keys.length; j += 1) { 111 | this.assetsByName.set(keys[j], { 112 | id, files, hash, siblings, 113 | }); 114 | } 115 | } 116 | 117 | return this.assetsByName; 118 | } 119 | 120 | /** 121 | * Get Application Entry points 122 | * @method getEntrypoints 123 | * @param {object} entrypoints - Webpack entry points 124 | * @returns {Set} - Application Entry points 125 | */ 126 | getEntrypoints(entrypoints) { 127 | const entry = Object.keys(entrypoints); 128 | for (let i = 0; i < entry.length; i += 1) { 129 | this.entrypoints.add(entry[i]); 130 | } 131 | 132 | return this.entrypoints; 133 | } 134 | 135 | /** 136 | * Get application chunk origin 137 | * @method getChunkOrigin 138 | * @param {object} id - Webpack application chunk id 139 | * @param {object} names - Webpack application chunk names 140 | * @param {object} modules - Webpack application chunk modules 141 | * @returns {array} Chunk Keys 142 | */ 143 | /* eslint-disable class-methods-use-this */ 144 | getChunkOrigin({ id, names, modules }) { 145 | const origins = new Set(); 146 | if (!WEBPACK_5) { 147 | // webpack 5 doesn't have 'reasons' on chunks any more 148 | // this is a dirty solution to make it work without throwing 149 | // an error, but does need tweaking to make everything work properly. 150 | for (let i = 0; i < modules.length; i += 1) { 151 | const { reasons } = modules[i]; 152 | for (let j = 0; j < reasons.length; j += 1) { 153 | const reason = reasons[j]; 154 | const type = reason.dependency ? reason.dependency.type : null; 155 | const userRequest = reason.dependency 156 | ? reason.dependency.userRequest 157 | : null; 158 | if (type === 'import()') { 159 | origins.add(userRequest); 160 | } 161 | } 162 | } 163 | } 164 | 165 | if (origins.size === 0) { return [names[0] || id]; } 166 | if (this.entrypoints.has(names[0])) { 167 | origins.add(names[0]); 168 | } 169 | 170 | return Array.from(origins); 171 | } 172 | /* eslint-enabled */ 173 | 174 | /** 175 | * Webpack apply method. 176 | * @method apply 177 | * @param {object} compiler - Webpack compiler object 178 | * It represents the fully configured Webpack environment. 179 | * @See {@link https://webpack.js.org/concepts/plugins/#anatomy} 180 | */ 181 | apply(compiler) { 182 | this.compiler = compiler; 183 | // @See {@Link https://webpack.js.org/api/compiler-hooks/} 184 | compiler.hooks.emit.tapAsync(PLUGIN_NAME, this.handleEmit.bind(this)); 185 | } 186 | 187 | /** 188 | * Get Minimal Stats Chunks 189 | * @description equivalent of getting stats.chunks but much less in size & memory usage 190 | * It tries to mimic https://github.com/webpack/webpack/blob/webpack-4/lib/Stats.js#L632 191 | * implementation without expensive operations 192 | * @param {array} compilationChunks 193 | * @returns {array} 194 | */ 195 | getMinimalStatsChunks(compilationChunks) { 196 | const compareId = (a, b) => { 197 | if (typeof a !== typeof b) { 198 | return typeof a < typeof b ? -1 : 1; 199 | } 200 | if (a < b) return -1; 201 | if (a > b) return 1; 202 | return 0; 203 | }; 204 | 205 | return this.ensureArray(compilationChunks).reduce((chunks, chunk) => { 206 | const siblings = new Set(); 207 | 208 | if (chunk.groupsIterable) { 209 | const chunkGroups = Array.from(chunk.groupsIterable); 210 | 211 | for (let i = 0; i < chunkGroups.length; i += 1) { 212 | const group = Array.from(chunkGroups[i].chunks); 213 | 214 | for (let j = 0; j < group.length; j += 1) { 215 | const sibling = group[j]; 216 | if (sibling !== chunk) siblings.add(sibling.id); 217 | } 218 | } 219 | } 220 | 221 | chunk.ids.forEach((id) => { 222 | chunks.push({ 223 | id, 224 | names: chunk.name ? [chunk.name] : [], 225 | files: this.ensureArray(chunk.files).slice(), 226 | hash: chunk.renderedHash, 227 | siblings: Array.from(siblings).sort(compareId), 228 | // TODO: This is the final deprecation warning needing to be solved. 229 | modules: chunk.getModules(), 230 | }); 231 | }); 232 | 233 | return chunks; 234 | }, []); 235 | } 236 | 237 | /** 238 | * Handles emit event from Webpack 239 | * @desc The Webpack Compiler begins with emitting the generated assets. 240 | * Here plugins have the last chance to add assets to the `c.assets` array. 241 | * @See {@Link https://github.com/webpack/docs/wiki/plugins#emitc-compilation-async} 242 | * @method handleEmit 243 | * @param {object} compilation 244 | * @param {function} callback 245 | */ 246 | handleEmit(compilation, callback) { 247 | this.stats = compilation.getStats().toJson({ 248 | all: false, 249 | entrypoints: true, 250 | }, true); 251 | this.options.publicPath = (compilation.outputOptions 252 | ? compilation.outputOptions.publicPath 253 | : compilation.options.output.publicPath) 254 | || ''; 255 | this.getEntrypoints(this.stats.entrypoints); 256 | 257 | this.getAssets(this.getMinimalStatsChunks(compilation.chunks)); 258 | this.processAssets(compilation.assets); 259 | this.writeAssetsFile(); 260 | 261 | callback(); 262 | } 263 | 264 | /** 265 | * Process Application Assets Manifest 266 | * @method processAssets 267 | * @param {object} originAssets - Webpack raw compilations assets 268 | */ 269 | /* eslint-disable object-curly-newline, no-restricted-syntax */ 270 | processAssets(originAssets) { 271 | const assets = {}; 272 | const origins = {}; 273 | const { entrypoints } = this; 274 | 275 | this.assetsByName.forEach((value, key) => { 276 | const { files, id, siblings, hash } = value; 277 | 278 | if (!origins[key]) { origins[key] = []; } 279 | 280 | siblings.push(id); 281 | 282 | for (let i = 0; i < siblings.length; i += 1) { 283 | const sibling = siblings[i]; 284 | if (!origins[key].includes(sibling)) { 285 | origins[key].push(sibling); 286 | } 287 | } 288 | 289 | for (let i = 0; i < files.length; i += 1) { 290 | const file = files[i]; 291 | const currentAsset = originAssets[file] || {}; 292 | const ext = getFileExtension(file).replace(/^\.+/, '').toLowerCase(); 293 | 294 | if (!assets[id]) { assets[id] = {}; } 295 | if (!assets[id][ext]) { assets[id][ext] = []; } 296 | 297 | if (!hasEntry(assets[id][ext], 'file', file)) { 298 | const shouldComputeIntegrity = Object.keys(currentAsset) 299 | && this.options.integrity 300 | && !currentAsset[this.options.integrityPropertyName]; 301 | 302 | if (shouldComputeIntegrity) { 303 | currentAsset[this.options.integrityPropertyName] = computeIntegrity( 304 | this.options.integrityAlgorithms, 305 | currentAsset.source(), 306 | ); 307 | } 308 | 309 | assets[id][ext].push({ 310 | file, 311 | hash, 312 | publicPath: url.resolve(this.options.publicPath || '', file), 313 | integrity: currentAsset[this.options.integrityPropertyName], 314 | }); 315 | } 316 | } 317 | }); 318 | 319 | // create assets manifest object 320 | this.manifest = { 321 | entrypoints: Array.from(entrypoints), 322 | origins, 323 | assets, 324 | }; 325 | } 326 | 327 | /** 328 | * Write Assets Manifest file 329 | * @method writeAssetsFile 330 | */ 331 | writeAssetsFile() { 332 | const filePath = this.manifestOutputPath; 333 | const fileDir = path.dirname(filePath); 334 | const json = JSON.stringify(this.manifest, null, 2); 335 | try { 336 | if (!fs.existsSync(fileDir)) { 337 | fs.mkdirSync(fileDir, { recursive: true }); 338 | } 339 | } catch (err) { 340 | if (err.code !== 'EEXIST') { 341 | throw err; 342 | } 343 | } 344 | 345 | fs.writeFileSync(filePath, json); 346 | } 347 | 348 | /** 349 | * Ensure that given source is an array (webpack 5 switches a lot of Arrays to Sets) 350 | * @method ensureArray 351 | * @function 352 | * @param {*[]|Set} source 353 | * @returns {*[]} 354 | */ 355 | ensureArray(source) { 356 | if (WEBPACK_5) { 357 | return Array.from(source); 358 | } 359 | return source; 360 | } 361 | } 362 | 363 | export { defaultOptions }; 364 | export default ReactLoadableSSRAddon; 365 | -------------------------------------------------------------------------------- /source/ReactLoadableSSRAddon.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import waitForExpect from 'wait-for-expect'; 5 | import webpack from 'webpack'; 6 | import config from '../webpack.config'; 7 | import ReactLoadableSSRAddon, { defaultOptions } from './ReactLoadableSSRAddon'; 8 | 9 | /* eslint-disable consistent-return, import/no-dynamic-require, global-require */ 10 | let outputPath; 11 | let manifestOutputPath; 12 | 13 | const runWebpack = (configuration, end, callback) => { 14 | webpack(configuration, (err, stats) => { 15 | if (err) { 16 | return end(err); 17 | } 18 | if (stats.hasErrors()) { 19 | return end(stats.toString()); 20 | } 21 | 22 | callback(); 23 | 24 | end(); 25 | }); 26 | }; 27 | 28 | test.beforeEach(() => { 29 | const publicPathSanitized = config.output.publicPath.slice(1, -1); 30 | outputPath = path.resolve('./example', publicPathSanitized); 31 | manifestOutputPath = path.resolve(outputPath, defaultOptions.filename); 32 | }); 33 | 34 | test.cb('outputs with default settings', (t) => { 35 | config.plugins = [ 36 | new ReactLoadableSSRAddon(), 37 | ]; 38 | 39 | runWebpack(config, t.end, () => { 40 | const feedback = fs.existsSync(manifestOutputPath) ? 'pass' : 'fail'; 41 | 42 | t[feedback](); 43 | }); 44 | }); 45 | 46 | test.cb('outputs with custom filename', (t) => { 47 | const filename = 'new-assets-manifest.json'; 48 | 49 | config.plugins = [ 50 | new ReactLoadableSSRAddon({ 51 | filename, 52 | }), 53 | ]; 54 | 55 | runWebpack(config, t.end, () => { 56 | const feedback = fs.existsSync(manifestOutputPath.replace(defaultOptions.filename, filename)) ? 'pass' : 'fail'; 57 | 58 | t[feedback](); 59 | }); 60 | }); 61 | 62 | test.cb('outputs with integrity', (t) => { 63 | config.plugins = [ 64 | new ReactLoadableSSRAddon({ 65 | integrity: true, 66 | }), 67 | ]; 68 | 69 | runWebpack(config, t.end, async () => { 70 | const manifest = require(`${manifestOutputPath}`); 71 | 72 | await waitForExpect(() => { 73 | Object.keys(manifest.assets).forEach((asset) => { 74 | manifest.assets[asset].js.forEach(({ integrity }) => { 75 | t.truthy(integrity); 76 | }); 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /source/getBundles.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | import { unique } from './utils'; 8 | 9 | /** 10 | * getBundles 11 | * @param {object} manifest - The assets manifest content generate by ReactLoadableSSRAddon 12 | * @param {array} chunks - Chunks list to be loaded 13 | * @returns {array} - Assets list group by file type 14 | */ 15 | /* eslint-disable no-param-reassign */ 16 | function getBundles(manifest, chunks) { 17 | if (!manifest || !chunks) { return {}; } 18 | 19 | const assetsKey = chunks.reduce((key, chunk) => { 20 | if (manifest.origins[chunk]) { 21 | key = unique([...key, ...manifest.origins[chunk]]); 22 | } 23 | return key; 24 | }, []); 25 | 26 | return assetsKey.reduce((bundle, asset) => { 27 | Object.keys(manifest.assets[asset] || {}).forEach((key) => { 28 | const content = manifest.assets[asset][key]; 29 | if (!bundle[key]) { bundle[key] = []; } 30 | bundle[key] = unique([...bundle[key], ...content]); 31 | }); 32 | return bundle; 33 | }, {}); 34 | } 35 | /* eslint-enabled */ 36 | 37 | export default getBundles; 38 | -------------------------------------------------------------------------------- /source/getBundles.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import path from 'path'; 3 | import getBundles from './getBundles'; 4 | import config from '../webpack.config'; 5 | import manifest from '../example/dist/react-loadable-ssr-addon'; // eslint-disable-line import/no-unresolved, import/extensions 6 | 7 | const modules = ['./Header', './multilevel/Multilevel', './SharedMultilevel', '../../SharedMultilevel']; 8 | const fileType = ['js']; 9 | let bundles; 10 | 11 | test.beforeEach(() => { 12 | bundles = getBundles(manifest, [...manifest.entrypoints, ...modules]); 13 | }); 14 | 15 | test('returns the correct bundle size and content', (t) => { 16 | t.true(Object.keys(bundles).length === fileType.length); 17 | fileType.forEach((type) => !!bundles[type]); 18 | }); 19 | 20 | test('returns the correct bundle infos', (t) => { 21 | fileType.forEach((type) => { 22 | bundles[type].forEach((bundle) => { 23 | const expectedPublichPath = path.resolve(config.output.publicPath, bundle.file); 24 | 25 | t.true(bundle.file !== ''); 26 | t.true(bundle.hash !== ''); 27 | t.true(bundle.publicPath === expectedPublichPath); 28 | }); 29 | }); 30 | }); 31 | 32 | test('returns nothing when there is no match', (t) => { 33 | bundles = getBundles(manifest, ['foo-bar', 'foo', null, undefined]); 34 | 35 | t.true(Object.keys(bundles).length === 0); 36 | }); 37 | 38 | test('should work even with null/undefined manifest or modules', (t) => { 39 | bundles = getBundles(manifest, null); 40 | t.true(Object.keys(bundles).length === 0); 41 | 42 | bundles = getBundles(null, []); 43 | t.true(Object.keys(bundles).length === 0); 44 | 45 | bundles = getBundles([], null); 46 | t.true(Object.keys(bundles).length === 0); 47 | }); 48 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | import ReactLoadableSSRAddon from './ReactLoadableSSRAddon'; 8 | import getBundles from './getBundles'; 9 | 10 | module.exports = ReactLoadableSSRAddon; 11 | module.exports.getBundles = getBundles; 12 | -------------------------------------------------------------------------------- /source/utils/computeIntegrity.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | import crypto from 'crypto'; 8 | 9 | /** 10 | * Compute SRI Integrity 11 | * @func computeIntegrity 12 | * See {@link https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity Subresource Integrity} at MDN 13 | * @param {array} algorithms - The algorithms you want to use when hashing `content` 14 | * @param {string} source - File contents you want to hash 15 | * @return {string} SRI hash 16 | */ 17 | function computeIntegrity(algorithms, source) { 18 | return Array.isArray(algorithms) 19 | ? algorithms.map((algorithm) => { 20 | const hash = crypto 21 | .createHash(algorithm) 22 | .update(source, 'utf8') 23 | .digest('base64'); 24 | return `${algorithm}-${hash}`; 25 | }).join(' ') 26 | : ''; 27 | } 28 | 29 | export default computeIntegrity; 30 | -------------------------------------------------------------------------------- /source/utils/getFileExtension.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | /** 8 | * Get file extension 9 | * @method getFileExtension 10 | * @static 11 | * @param {string} filename - File name 12 | * @returns {string} - File extension 13 | */ 14 | function getFileExtension(filename) { 15 | if (!filename || typeof filename !== 'string') { return ''; } 16 | 17 | const fileExtRegex = /\.\w{2,4}\.(?:map|gz)$|\.\w+$/i; 18 | 19 | const name = filename.split(/[?#]/)[0]; // eslint-disable-line prefer-destructuring 20 | const ext = name.match(fileExtRegex); 21 | 22 | return ext && ext.length ? ext[0] : ''; 23 | } 24 | 25 | export default getFileExtension; 26 | -------------------------------------------------------------------------------- /source/utils/getFileExtension.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import getFileExtension from './getFileExtension'; 3 | 4 | test('returns the correct file extension', (t) => { 5 | const extensions = ['.jpeg', '.js', '.css', '.json', '.xml']; 6 | const filePath = 'source/static/images/hello-world'; 7 | 8 | extensions.forEach((ext) => { 9 | t.true(getFileExtension(`${filePath}${ext}`) === ext); 10 | }); 11 | }); 12 | 13 | test('sanitize file hash', (t) => { 14 | const hashes = ['?', '#']; 15 | const filePath = 'source/static/images/hello-world.jpeg'; 16 | 17 | hashes.forEach((hash) => { 18 | t.true(getFileExtension(`${filePath}${hash}d587bbd6e38337f5accd`) === '.jpeg'); 19 | }); 20 | }); 21 | 22 | test('returns empty string when there is no file extension', (t) => { 23 | const filePath = 'source/static/resource'; 24 | 25 | t.true(getFileExtension(filePath) === ''); 26 | }); 27 | 28 | test('should work even with null/undefined arg', (t) => { 29 | const filePaths = ['', null, undefined]; 30 | 31 | filePaths.forEach((path) => { 32 | t.true(getFileExtension(path) === ''); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /source/utils/hasEntry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | /** 8 | * Checks if object array already contains given value 9 | * @method hasEntry 10 | * @function 11 | * @param {array} target - Object array to be inspected 12 | * @param {string} targetKey - Object key to look for 13 | * @param {string} searchFor - Value to search existence 14 | * @returns {boolean} 15 | */ 16 | export default function hasEntry(target, targetKey, searchFor) { 17 | if (!target) { return false; } 18 | 19 | for (let i = 0; i < target.length; i += 1) { 20 | const file = target[i][targetKey]; 21 | if (file === searchFor) { return true; } 22 | } 23 | 24 | return false; 25 | } 26 | -------------------------------------------------------------------------------- /source/utils/hasEntry.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import hasEntry from './hasEntry'; 3 | 4 | const assets = [ 5 | { 6 | 7 | file: 'content.chunk.js', 8 | hash: 'd41d8cd98f00b204e9800998ecf8427e', 9 | publicPath: './', 10 | integrity: null, 11 | }, 12 | { 13 | 14 | file: 'header.chunk.js', 15 | hash: '699f4bd49870f2b90e1d1596d362efcb', 16 | publicPath: './', 17 | integrity: null, 18 | }, 19 | { 20 | 21 | file: 'shared-multilevel.chunk.js', 22 | hash: 'ab7b8b1c1d5083c17a39ccd2962202e1', 23 | publicPath: './', 24 | integrity: null, 25 | }, 26 | ]; 27 | 28 | test('should flag as has entry', (t) => { 29 | const fileName = 'header.chunk.js'; 30 | 31 | t.true(hasEntry(assets, 'file', fileName)); 32 | }); 33 | 34 | test('should flag as has no entry', (t) => { 35 | const fileName = 'footer.chunk.js'; 36 | 37 | t.false(hasEntry(assets, 'file', fileName)); 38 | }); 39 | 40 | test('should work even with null/undefined target', (t) => { 41 | const targets = [[], null, undefined]; 42 | 43 | targets.forEach((target) => { 44 | t.false(hasEntry(target, 'file', 'foo.js')); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /source/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as computeIntegrity } from './computeIntegrity'; 2 | export { default as getFileExtension } from './getFileExtension'; 3 | export { default as unique } from './unique'; 4 | export { default as hasEntry } from './hasEntry'; 5 | -------------------------------------------------------------------------------- /source/utils/unique.js: -------------------------------------------------------------------------------- 1 | /** 2 | * react-loadable-ssr-addon 3 | * @author Marcos Gonçalves 4 | * @version 1.0.2 5 | */ 6 | 7 | /** 8 | * Clean array to unique values 9 | * @method unique 10 | * @function 11 | * @param {array} array - Array to be inspected 12 | * @returns {array} - Array with unique values 13 | */ 14 | export default function unique(array) { 15 | return array.filter((elem, pos, arr) => arr.indexOf(elem) === pos); 16 | } 17 | -------------------------------------------------------------------------------- /source/utils/unique.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import unique from './unique'; 3 | 4 | test('it filters duplicated entries', (t) => { 5 | const duplicated = ['two', 'four']; 6 | const raw = ['one', 'two', 'three', 'four']; 7 | const filtered = unique([...raw, ...duplicated]); 8 | 9 | duplicated.forEach((dup) => { 10 | t.true(filtered.filter((item) => item === dup).length === 1); 11 | }); 12 | }); 13 | 14 | test('should work with null/undefined values', (t) => { 15 | const falsy = [null, undefined]; 16 | const raw = ['one', 'two', 'three', 'four']; 17 | const filtered = unique([...raw, ...falsy]); 18 | 19 | falsy.forEach((value) => { 20 | t.true(filtered.includes(value)); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const ReactLoadableSSRAddon = require('./lib'); 4 | 5 | const HOST = process.env.HOST || '127.0.0.1'; 6 | const PORT = process.env.PORT || '8080'; 7 | 8 | module.exports = { 9 | mode: 'production', 10 | target: 'web', 11 | entry: { 12 | index: './example/client.jsx', 13 | }, 14 | devtool: 'eval-cheap-module-source-map', 15 | output: { 16 | publicPath: '/dist/', 17 | path: path.join(__dirname, 'example', 'dist'), 18 | filename: '[name].js', 19 | chunkFilename: '[name].chunk.js', 20 | }, 21 | resolve: { 22 | extensions: ['.js', '.jsx'], 23 | alias: { 24 | 'react-loadable-ssr-addon': path.resolve(__dirname, './source'), 25 | }, 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.jsx?$/, 31 | exclude: /(node_modules|bower_components|public\/)/, 32 | use: { 33 | loader: 'babel-loader', 34 | options: { 35 | babelrc: false, 36 | presets: [ 37 | '@babel/preset-env', 38 | '@babel/preset-react', 39 | ], 40 | plugins: [ 41 | require('@babel/plugin-proposal-class-properties'), 42 | require('@babel/plugin-proposal-object-rest-spread'), 43 | require('@babel/plugin-syntax-dynamic-import'), 44 | require('react-loadable/babel'), 45 | ], 46 | }, 47 | }, 48 | }, 49 | ], 50 | }, 51 | plugins: [ 52 | new ReactLoadableSSRAddon({ 53 | filename: 'react-loadable-ssr-addon.json', 54 | }), 55 | ], 56 | performance: false, 57 | }; 58 | --------------------------------------------------------------------------------