├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── SUPPORT.md ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── buildpack-logo.icns ├── circle.yml ├── docs ├── MagentoResolver.md ├── MagentoRootComponentsPlugin.md ├── PWADevServer.md ├── ServiceWorkerPlugin.md └── magento-layout-loader.md ├── package-lock.json ├── package.json └── src ├── ExtensionComponentWrap ├── .babelrc ├── .eslintrc.js └── index.js ├── WebpackTools ├── MagentoResolver.js ├── PWADevServer.js ├── __tests__ │ ├── MagentoResolver.spec.js │ └── PWADevServer.spec.js ├── index.js ├── middlewares │ ├── DevProxy.js │ ├── OriginSubstitution.js │ ├── StaticRootRoute.js │ ├── __fixtures__ │ │ └── root-available-file.json │ └── __tests__ │ │ ├── DevProxy.spec.js │ │ ├── OriginSubstitution.spec.js │ │ └── StaticRootRoute.spec.js └── plugins │ ├── MagentoRootComponentsPlugin │ ├── __tests__ │ │ ├── MagentoRootComponentsPlugin.spec.js │ │ └── __fixtures__ │ │ │ ├── basic-project-1-page │ │ │ ├── RootComponents │ │ │ │ └── SomePage │ │ │ │ │ └── index.js │ │ │ └── entry.js │ │ │ ├── basic-project-3-pages │ │ │ ├── RootComponents │ │ │ │ ├── Page1 │ │ │ │ │ └── index.js │ │ │ │ ├── Page2 │ │ │ │ │ └── index.js │ │ │ │ └── Page3 │ │ │ │ │ └── index.js │ │ │ └── entry.js │ │ │ ├── dupe-root-component │ │ │ ├── RootComponents │ │ │ │ └── Page1 │ │ │ │ │ └── index.js │ │ │ └── entry.js │ │ │ ├── missing-root-directive │ │ │ ├── RootComponents │ │ │ │ └── Page1 │ │ │ │ │ └── index.js │ │ │ └── entry.js │ │ │ └── root-component-dep │ │ │ ├── RootComponents │ │ │ └── Page1 │ │ │ │ └── index.js │ │ │ ├── dep.js │ │ │ └── entry.js │ ├── index.js │ └── roots-chunk-loader.js │ ├── ServiceWorkerPlugin.js │ └── __tests__ │ └── ServiceWorkerPlugin.spec.js ├── __tests__ └── __fixtures__ │ └── extensions │ ├── extension1 │ └── extension.json │ ├── extension2 │ └── extension.json │ └── random-file.txt ├── index.js ├── magento-layout-loader ├── __tests__ │ ├── __fixtures__ │ │ └── only-entry │ │ │ └── index.js │ ├── __snapshots__ │ │ └── babel-plugin-magento-layout.spec.js.snap │ ├── babel-plugin-magento-layout.spec.js │ ├── magento-layout-loader.spec.js │ └── validateConfig.spec.js ├── babel-plugin-magento-layout.js ├── index.js └── validateConfig.js └── util ├── __tests__ ├── debug.spec.js ├── global-config.spec.js ├── options-validator.spec.js ├── run-as-root.spec.js └── ssl-cert-store.spec.js ├── debug.js ├── global-config.js ├── options-validator.js ├── promisified ├── child_process.js ├── dns.js ├── fs.js └── openport.js ├── run-as-root.js └── ssl-cert-store.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": [ 8 | "8" 9 | ] 10 | } 11 | } 12 | ] 13 | ], 14 | "plugins": [ 15 | ["transform-class-properties", { "spec": true }] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __fixtures__ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | parser: 'babel-eslint', 3 | parserOptions: { 4 | sourceType: 'script' 5 | }, 6 | extends: ['@magento', "plugin:node/recommended"], 7 | plugins: [ 8 | "babel", 9 | "node" 10 | ] 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /.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 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pwa@magento.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to the pwa-buildpack project! Before you start contributing, please take a moment to read through the following guidelines: 4 | 5 | * [Code of Conduct] 6 | * [Support] 7 | 8 | To contribute to this repository, start by forking the [official repository] and following the installation instructions in the README file. 9 | 10 | ## Pull Request checklist 11 | 12 | * PR commits should contain [meaningful commit messages] 13 | * To help with reviews, your PR should only create/revise a single feature or fix a single issue. 14 | * If your PR fixes a bug, please provide a step-by-step description of how to reproduce the bug. 15 | * If your PR addresses an existing issue, please reference that issue in the title or description. 16 | 17 | ## Contribution process 18 | 19 | Magento maintains a public roadmap for this and other [Magento Research] repositories in each project's issue board. 20 | 21 | Any and all community participation in this backlog is encouraged and appreciated. 22 | Even foundational infrastructure stories are available for a generous developer to take on. 23 | 24 | To get started, look for issues tagged with the **[help wanted]** labels. 25 | These issues are ready for community ownership. 26 | 27 | **Note:** 28 | *We also accept unsolicited new issues/features and pull request, but priority is given to issues in our roadmap that community developers have been kind enough to take on.* 29 | 30 | ### Claiming an issue on the roadmap 31 | 32 | If you are interested in taking ownership of a roadmap feature or issue, we ask that you go through the following process. 33 | This helps us organize and forecast the progress of our projects. 34 | 35 | #### Step 1: Add an issue comment 36 | 37 | Add a comment on an issue expressing your interest in taking ownership of it. 38 | Make sure your GitHub profile includes an email address so we can contact you privately. 39 | 40 | #### Step 2: Meet with a maintainer 41 | 42 | A maintainer will contact you and ask to set up a real-time meeting to discuss the issue you are interested in owning. 43 | This meeting can be in person, video chat, audio chat, or text chat. 44 | 45 | In general this meeting is brief but can vary with the complexity of an issue. 46 | For larger issues, we may schedule follow-up meetings. 47 | 48 | During this meeting, we provide you with any additional materials or resources you need to work on the issue. 49 | 50 | #### Step 3: Provide an estimate 51 | 52 | We ask that you provide us an estimate of how long it will take you to complete the issue. 53 | 54 | If you require more time to provide a time frame for completion, you are allowed to take up to five business days to think about it. 55 | 56 | If you can't get back to us by that time, we understand! 57 | As a community developer, you are helping us out in addition to your regular job. 58 | We will un-assign you from this issue, but please feel free to contribute to another issue. 59 | 60 | #### Step 4: Work on the issue 61 | 62 | After you provide an estimate, the issue is now "in progress", and 63 | you officially become a member of the [Magento Research] organization. 64 | 65 | If you need more time to work on the issue, please contact us as soon as possible. 66 | We may request an update on your progress, but we are willing to accomodate. 67 | 68 | If the deadline you provided to us passes and we have not heard from you, we will wait one week before un-assigning you from the issue. 69 | 70 | #### Step 5: Create a pull request 71 | 72 | When you finish working on an issue, create a pull request with the issue number included in the title or body. 73 | This starts the (brief) code review process. 74 | 75 | After we accept and merge your contribution, you become an official contributor! 76 | Official contributors are invited to our backlog grooming sessions and have direct influence over the product roadmap. 77 | 78 | We hope this guide paints a clear picture of your duties and expectations in the contribution process. Thank you in advance for helping with our research projects! 79 | 80 | ## Report an issue 81 | 82 | Create a [GitHub issue] and put an **X** in the appropriate box to report an issue with the project. 83 | Provide as much detail as you can in each section to help us triage and process the issue. 84 | 85 | ### Issue types 86 | 87 | * Bug - An error, flaw, or failure in the code 88 | * Feature suggestion - A missing feature you would like to see implemented in the project 89 | * Other - Any other type of task related to the project 90 | 91 | **Note:** 92 | *Please avoid creating GitHub issues asking for help on bugs in your project that are outside the scope of this project.* 93 | 94 | [Code of Conduct]: CODE_OF_CONDUCT.md 95 | [Support]: SUPPORT.md 96 | [official repository]: https://github.com/magento-research/pwa-buildpack 97 | [meaningful commit messages]: https://chris.beams.io/posts/git-commit/ 98 | [GitHub issue]: https://github.com/magento-research/pwa-buildpack/issues/new 99 | [Magento Research]: https://github.com/magento-research 100 | [help wanted]: https://github.com/magento-research/pwa-buildpack/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | ## This issue is a: 3 | [ ] Bug 4 | [ ] Feature suggestion 5 | [ ] Other 6 | 7 | 8 | ## Description: 9 | 10 | 11 | ### Environment and steps to reproduce 12 | 13 | OS: 14 | 15 | Magento 2 version: 16 | 17 | Other environment information: 18 | 19 | Steps to reproduce: 20 | 21 | 1. First Step 22 | 2. Second Step 23 | 3. Etc. 24 | 25 | 26 | ## Expected result: 27 | 28 | 29 | ## Possible solutions: 30 | 31 | 32 | ## Additional information: 33 | 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | ## This PR is a: 3 | [ ] New feature 4 | [ ] Enhancement/Optimization 5 | [ ] Refactor 6 | [ ] Bugfix 7 | [ ] Test for existing code 8 | [ ] Documentation 9 | 10 | 11 | ## Summary 12 | 13 | When this pull request is merged, it will... 14 | 15 | 16 | ## Additional information 17 | 18 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | Need help with something? Please use the following resources to get the help you need: 4 | 5 | * Documentation website - [PWA DevDocs] 6 | * Chat with us on **Slack** - [#pwa channel] 7 | * Send us an Email: pwa@magento.com 8 | 9 | [PWA DevDocs]: https://magento-research.github.io/pwa-devdocs/ 10 | [#pwa channel]: https://magentocommeng.slack.com/messages/C71HNKYS2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | coverage 5 | dist 6 | test-report.xml 7 | .vscode 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "tabWidth": 4 5 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [0.8.2](https://github.com/magento-research/pwa-buildpack/compare/v0.8.1...v0.8.2) (2018-05-26) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * update devcert dependency ([ed4c1d5](https://github.com/magento-research/pwa-buildpack/commit/ed4c1d5)) 12 | 13 | 14 | 15 | 16 | ## [0.8.1](https://github.com/magento-research/pwa-buildpack/compare/v0.7.1...v0.8.1) (2018-05-26) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * remove buggy sudo-prompt until fallback works ([#39](https://github.com/magento-research/pwa-buildpack/issues/39)) ([99828aa](https://github.com/magento-research/pwa-buildpack/commit/99828aa)), closes [#35](https://github.com/magento-research/pwa-buildpack/issues/35) 22 | 23 | 24 | 25 | 26 | ## [0.7.1](https://github.com/magento-research/pwa-buildpack/compare/v0.7.0...v0.7.1) (2018-05-15) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **DevProxy:** put proxy after other middlewares ([#33](https://github.com/magento-research/pwa-buildpack/issues/33)) ([6378414](https://github.com/magento-research/pwa-buildpack/commit/6378414)), closes [#32](https://github.com/magento-research/pwa-buildpack/issues/32) 32 | 33 | 34 | 35 | 36 | # [0.7.0](https://github.com/magento-research/pwa-buildpack/compare/v0.6.0...v0.7.0) (2018-05-03) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * **util:** fix run-as-root await rm bug ([#28](https://github.com/magento-research/pwa-buildpack/issues/28)) ([8a71a9f](https://github.com/magento-research/pwa-buildpack/commit/8a71a9f)), closes [magento-research/venia-pwa-concept#51](https://github.com/magento-research/venia-pwa-concept/issues/51) 42 | 43 | 44 | ### Features 45 | 46 | * add protocol error detection to devproxy ([#22](https://github.com/magento-research/pwa-buildpack/issues/22)) ([aa0e53c](https://github.com/magento-research/pwa-buildpack/commit/aa0e53c)) 47 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Open Software License ("OSL") v. 3.0 4 | 5 | This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: 6 | 7 | Licensed under the Open Software License version 3.0 8 | 9 | 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: 10 | 11 | 1. to reproduce the Original Work in copies, either alone or as part of a collective work; 12 | 13 | 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; 14 | 15 | 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; 16 | 17 | 4. to perform the Original Work publicly; and 18 | 19 | 5. to display the Original Work publicly. 20 | 21 | 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. 22 | 23 | 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. 24 | 25 | 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. 26 | 27 | 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). 28 | 29 | 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. 30 | 31 | 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. 32 | 33 | 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. 34 | 35 | 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). 36 | 37 | 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. 38 | 39 | 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. 40 | 41 | 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. 42 | 43 | 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. 44 | 45 | 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 46 | 47 | 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. 48 | 49 | 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository is no longer maintained. Please visit the [PWA Studio](https://github.com/magento-research/pwa-studio) repository which now contains all PWA Studio packages in one place. 2 | -------------------------------------------------------------------------------- /buildpack-logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magento-research/pwa-buildpack/868805288d6d5cc16a87382d50a4a89e2fd8be0a/buildpack-logo.icns -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8 4 | environment: 5 | TEST_REPORT_PATH: $CIRCLE_TEST_REPORTS/ 6 | -------------------------------------------------------------------------------- /docs/MagentoResolver.md: -------------------------------------------------------------------------------- 1 | # MagentoResolver 2 | 3 | An adapter that configures Webpack to resolve assets according to Magento PWA conventions. 4 | 5 | ## Purpose 6 | 7 | Generates a configuration for use in the [`resolve` property of Webpack config](https://webpack.js.org/configuration/resolve/). 8 | Describes how to traverse the filesystem structure for assets required in source 9 | files. 10 | 11 | This class generates a configuration object for the `resolve` property of a 12 | Webpack config file. The configuration object describes how Webpack should 13 | traverse the filesystem structure to retrieve assets required in source files. 14 | 15 | Currently, `MagentoResolver` does very little, but it's likely that the Magento 16 | development environment will require custom resolution rules in the future; this 17 | utility sets the precedent of the API for delivering those rules. 18 | 19 | ## Usage 20 | 21 | In `webpack.config.js`: 22 | 23 | ```js 24 | const buildpack = require('@magento/pwa-buildpack'); 25 | const MagentoResolver = buildpack.Webpack.MagentoResolver; 26 | 27 | module.exports = async env => { 28 | const config { 29 | /* webpack entry, output, rules, etc */ 30 | 31 | 32 | resolve: await MagentoResolver.configure({ 33 | paths: { 34 | root: __dirname 35 | } 36 | }) 37 | 38 | }; 39 | 40 | return config; 41 | } 42 | ``` 43 | 44 | - ℹ️ **Note:** `MagentoResolver.configure()` is asynchronous and returns a 45 | Promise. For more information, see the Webpack documentation about 46 | [Exporting a Promise configuration type](https://webpack.js.org/configuration/configuration-types/#exporting-a-promise). 47 | 48 | In the example provided, lhe newer `async/await` syntax is used because it is 49 | a cleaner alternative to using Promses directly. 50 | 51 | ### API 52 | 53 | #### `MagentoResolver.configure(options: ResolverOptions): Promise` 54 | 55 | #### `options` 56 | 57 | - `paths: object`: **Required.** Local absolute paths to theme folders. 58 | - `root`: Absolute path to the root directory of the theme. 59 | -------------------------------------------------------------------------------- /docs/MagentoRootComponentsPlugin.md: -------------------------------------------------------------------------------- 1 | # MagentoRootComponentsPlugin 2 | 3 | Automagically creates [unique 4 | chunks](https://webpack.js.org/guides/code-splitting/) for each Root Component 5 | in a Magento PWA theme and extensions. 6 | 7 | Given a `RootComponents` directory in a theme with the following structure: 8 | 9 | ```sh 10 | ├── Page1 11 | │ └── index.js 12 | ├── Page2 13 | │ └── index.js 14 | └── Page3 15 | └── index.js 16 | ``` 17 | 18 | a unique chunk will be generated for `Page1`, `Page2`, and `Page3`. Further 19 | `webpack` optimization techniques (`CommonsChunkPlugin` et al) can be applied as 20 | usual. 21 | 22 | ## Usage 23 | 24 | ```js 25 | // webpack.config.js 26 | 27 | const path = require('path'); 28 | const { MagentoRootComponentsPlugin } = require('@magento/pwa-buildpack'); 29 | 30 | module.exports = { 31 | entry: { 32 | main: path.join(__dirname, 'src') 33 | }, 34 | output: { 35 | path: path.join(__dirname, 'dist'), 36 | filename: '[name].js', 37 | chunkFilename: '[name].chunk.js' 38 | }, 39 | plugins: [ 40 | new MagentoRootComponentsPlugin({ 41 | rootComponentsDirs: [path.join(__dirname, 'src/RootComponents')], // optional 42 | manifestFileName: 'roots-manifest.json' // optional 43 | }) 44 | ] 45 | }; 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/PWADevServer.md: -------------------------------------------------------------------------------- 1 | # PWADevServer 2 | 3 | Utility for configuring a development OS and a `webpack-dev-server` for PWA 4 | development. 5 | 6 | ## Usage 7 | 8 | In `webpack.config.js`: 9 | 10 | ```js 11 | const path = require('path'); 12 | const buildpack = require('@magento/pwa-buildpack'); 13 | const PWADevServer = buildpack.Webpack.PWADevServer; 14 | 15 | module.exports = async env => { 16 | const config { 17 | /* webpack entry, output, rules, etc */ 18 | 19 | devServer: await PWADevServer.configure({ 20 | publicPath: '/pub/static/frontend/Vendor/theme/en_US/', 21 | backendDomain: 'https://magento2.localdomain', 22 | serviceWorkerFileName: 'sw.js', 23 | paths: { 24 | output: path.resolve(__dirname, 'web/js'), 25 | assets: path.resolve(__dirname, 'web') 26 | }, 27 | id: 'magento-venia' 28 | }) 29 | }; 30 | 31 | config.output.publicPath = config.devServer.publicPath; 32 | 33 | return config; 34 | } 35 | ``` 36 | 37 | - ⚠️ `PWADevServer.configure()` is async and returns a Promise, so a webpack 38 | config that uses it must use the [Exporting a Promise configuration type](https://webpack.js.org/configuration/configuration-types/#exporting-a-promise). 39 | The newer `async/await` syntax looks cleaner than using Promises directly. 40 | - ⚠️ The emitted `devServer` object may have a custom `publicPath`. For best 41 | ServiceWorker functionality, set `config.output.publicPath` to this value 42 | once the `devServer` is created and before creating a ServiceWorker plugin. 43 | 44 | ### Purpose 45 | 46 | The typical webpack local development scenario uses [the devServer settings in 47 | `webpack.config.js`](https://webpack.js.org/configuration/dev-server/) to create 48 | a temporary local HTTP server for showing edits in real time. 49 | 50 | PWA development has a couple of particular needs: 51 | 52 | - The host must be _secure_ and _trusted_ to allow ServiceWorker operation 53 | - The host must be _unique_ to prevent ServiceWorker collisions 54 | 55 | Furthermore, Magento PWAs are Magento 2 themes running on Magento 2 stores, so 56 | they need to proxy backend requests to the backing store in a customized way. 57 | 58 | PWADevServer` handles all these needs: 59 | 60 | - Creates and caches a custom local hostname for the current theme 61 | - Adds the custom local hostname to `/etc/hosts` 🔐 62 | - Creates and caches an SSL certificate for the custom local hostname 63 | - Adds the certificate to the OS-level keychain so browsers trust it 🔐 64 | - Customizes the `webpack-dev-server` instance to: 65 | - Proxy all asset requests not managed by webpack to the Magento store 66 | - Emulate the public path settings of the Magento store 67 | - Automatically switch domain names in HTML attributes 68 | - Debug or disable ServiceWorkers 69 | 70 | *The 🔐 in the above list indicates that you may be asked for a password at 71 | this step.* 72 | 73 | ### API 74 | 75 | `PWADevServer` has only one method: `.configure(options)`. It returns a Promise 76 | for an object that can be assigned to the `devServer` property in webpack 77 | configuration. 78 | 79 | #### `PWADevServer.configure(options: PWADevServerOptions): Promise` 80 | 81 | #### `options` 82 | 83 | - `id: string`: **Required.** A unique ID for this project. Theme name is 84 | recommended, but you can use any domain-name-safe string. If you're 85 | developing several copies of a theme simultaneously, you can use this ID to 86 | distinguish them in the internal tooling; for example, this id will be used 87 | to create your dev domain name. 88 | - `publicPath: string`: **Required.** The public path of theme assets in the 89 | backend server, e.g. `'/pub/static/frontend/Vendor/themename/en_US'`. 90 | - `backendDomain: string`: **Required.** The URL of the backing store. 91 | - `paths: object`: **Required.** Local absolute paths to theme folders. 92 | - `output`: Directory for built JavaScript files. 93 | - `assets`: Directory for other public static assets. 94 | - `serviceWorkerFileName: string`: **Required.** The name of the ServiceWorker 95 | file this theme creates, e.g. `'sw.js'`. 96 | - `changeOrigin: boolean`: ⚠️ **(experimental)** Try to parse any HTML responses 97 | from the proxied Magento backend, and replace its domain name with the 98 | dev server domain name. Default `false`. 99 | -------------------------------------------------------------------------------- /docs/ServiceWorkerPlugin.md: -------------------------------------------------------------------------------- 1 | # ServiceWorkerPlugin 2 | 3 | Webpack plugin for configuring a ServiceWorker for different PWA development 4 | scenarios. 5 | 6 | ## Purpose 7 | 8 | This plugin is a wrapper around the [Google Workbox Webpack Plugin](https://developers.google.com/web/tools/workbox/guides/generate-service-worker/). 9 | It generates a caching ServiceWorker based on assets emitted by Webpack. 10 | 11 | This plugin can be configured to run in the following phases: 12 | 13 | - *normal development* - ServiceWorker is disabled 14 | - *service worker debugging* - ServiceWorker and hot-reloading are enabled 15 | 16 | ## Usage 17 | 18 | In `webpack.config.js`: 19 | 20 | ```js 21 | const path = require('path'); 22 | const buildpack = require('@magento/pwa-buildpack'); 23 | const ServiceWorkerPlugin = buildpack.Webpack.ServiceWorkerPlugin; 24 | 25 | module.exports = async env => { 26 | const config = { 27 | /* webpack config, i.e. entry, output, etc. */ 28 | plugins: [ 29 | /* other plugins */ 30 | new ServiceWorkerPlugin({ 31 | env: { 32 | mode: 'development' 33 | }, 34 | 35 | paths: { 36 | output: path.resolve(__dirname, 'web/js'), 37 | assets: path.resolve(__dirname, 'web') 38 | }, 39 | enableServiceWorkerDebugging: true, 40 | serviceWorkerFileName: 'sw.js', 41 | runtimeCacheAssetPath: 'https://cdn.url' 42 | }) 43 | ] 44 | }; 45 | 46 | return config; 47 | 48 | }; 49 | ``` 50 | 51 | ### API 52 | 53 | `ServiceWorkerPlugin(options: PluginOptions): Plugin` 54 | Plugin constructor for the `ServiceWorkerPlugin` class. 55 | 56 | #### `PluginOptions` 57 | 58 | `env: Object` **(Required)** 59 | An object that represents the current environment. 60 | - `env.phase: String` **(Required)** 61 | Must be either `'development'` or `'production'`. 62 | 63 | `paths: Object` **(Required)** 64 | The local absolute paths to theme folders. 65 | 66 | - `paths.assets: String` 67 | 68 | The directory for public static assets. 69 | 70 | `enableServiceWorkerDebugging: Boolean` 71 | When `true`, hot reloading is enabled and the ServiceWorker is active in the document root, regardless of the publicPath value. 72 | When `false`, the ServiceWorker is disabled to prevent cache interruptions when hot reloading assets. 73 | 74 | `serviceWorkerFileName: String` **(Required)** 75 | The name of the ServiceWorker file this theme creates. 76 | Example: `'sw.js'` 77 | 78 | `runtimeCacheAssetPath: String` 79 | A path or remote URL that represents the root path to assets the ServiceWorker should cache as requested during runtime. 80 | -------------------------------------------------------------------------------- /docs/magento-layout-loader.md: -------------------------------------------------------------------------------- 1 | # magento-layout-loader 2 | 3 | _This is a very early implementation, and the API should be considered 4 | unstable._ 5 | 6 | The `magento-layout-loader` is a [webpack 7 | loader](https://webpack.js.org/concepts/loaders/) that transforms 8 | [JSX](https://reactjs.org/docs/introducing-jsx.html) during compilation. It 9 | gives Magento modules/extensions the ability to inject or remove content blocks 10 | in a layout without modifying theme source files. 11 | 12 | ## Terminology 13 | 14 | * **Container**: An HTML element that contains 0 or more ContainerChild 15 | components. It acts as the target for the magento-loader-layout operations. 16 | * **ContainerChild**: Component exposed by 17 | [Peregrine](https://github.com/magento-research/peregrine/). Responsible for 18 | rendering content 19 | * **Operation**: An action that can be taken on a `Container` or 20 | `ContainerChild`. Examples include `removeContainer`, `insertAfter`, etc. 21 | 22 | ## `Container` Details 23 | 24 | A Container can be created by adding a `data-mid` prop to any DOM element 25 | (`div`/`span`/etc) in any React component. There are a limited number of 26 | restrictions with Containers to be aware of: 27 | 28 | * The `data-mid` prop _must_ be a literal string value - it cannot be a dynamic 29 | value, or a variable reference 30 | * The direct descendants of a Container can only be a single component type - 31 | `ContainerChild` from 32 | [Peregrine](https://github.com/magento-research/peregrine/) 33 | * A Container _must_ be a DOM element - it cannot be a Composite Component 34 | 35 | ## `ContainerChild` Details 36 | 37 | Import the `ContainerChild` component from `@magento/peregrine` to use it in 38 | your extension/module: 39 | 40 | ```js 41 | import { ContainerChild } from '@magento/peregrine'; 42 | ``` 43 | 44 | See the 45 | [`ContainerChild`](https://github.com/magento-research/peregrine/blob/master/docs/ContainerChild.md) 46 | documentation for further details on usage. 47 | 48 | ## Supported Operations/Configurations 49 | 50 | ### Fields 51 | 52 | | Config Name | Description | 53 | | ----------------- | :--------------------------------------------------------------------------------------: | 54 | | `operation` | One of the supported types of operations | 55 | | `targetContainer` | The `data-mid` value of the `Container` to target | 56 | | `targetChild` | The `id` value of the `ContainerChild` to target within `targetContainer` | 57 | | `componentPath` | An absolute path pointing to a file containing a React component as the `default` export | 58 | 59 | ### Operations 60 | 61 | #### removeContainer 62 | 63 | ##### Configuration 64 | 65 | ```js 66 | { 67 | "operation": "removeContainer", 68 | "targetContainer": "any.container.id" 69 | } 70 | ``` 71 | 72 |
73 | Example 74 | 75 | ##### Input 76 | 77 | ```js 78 | import React from 'react'; 79 | 80 | function render() { 81 | return ( 82 |
83 | The div below will be removed 84 |
85 |
86 | ); 87 | } 88 | ``` 89 | 90 | ##### Output 91 | 92 | ```js 93 | import React from 'react'; 94 | 95 | function render() { 96 | return
The div below will be removed
; 97 | } 98 | ``` 99 | 100 |
101 | 102 | #### removeChild 103 | 104 | ##### Configuration 105 | 106 | ```js 107 | { 108 | "operation": "removeChild", 109 | "targetContainer": "any.container.id", 110 | "targetChild": "container.child.id" 111 | } 112 | ``` 113 | 114 |
115 | Example 116 | 117 | ##### Input 118 | 119 | ```js 120 | import React from 'react'; 121 | import { ContainerChild } from '@magento/peregrine'; 122 | 123 | function render() { 124 | return ( 125 |
126 | The container below will be removed 127 |
This content will be removed
} 130 | /> 131 |
132 | ); 133 | } 134 | ``` 135 | 136 | ##### Output 137 | 138 | ```js 139 | import React from 'react'; 140 | 141 | function render() { 142 | return ( 143 |
144 | The container below will be removed 145 |
146 | ); 147 | } 148 | ``` 149 | 150 |
151 | 152 | #### insertBefore 153 | 154 | ##### Configuration 155 | 156 | ```js 157 | { 158 | "operation": "insertBefore", 159 | "targetContainer": "any.container.id", 160 | "targetChild": "container.child.id", 161 | "componentPath": "/Absolute/path/to/a/component.js" 162 | } 163 | ``` 164 | 165 |
166 | Example 167 | 168 | ##### Input 169 | 170 | ```js 171 | import React from 'react'; 172 | import { ContainerChild } from '@magento/peregrine'; 173 | 174 | function render() { 175 | return ( 176 |
177 |
Some Content
} 180 | /> 181 |
182 | ); 183 | } 184 | ``` 185 | 186 | ##### Output 187 | 188 | ```js 189 | import React from 'react'; 190 | import { ContainerChild } from '@magento/peregrine'; 191 | import _Extension from '/Absolute/path/to/a/component.js'; 192 | 193 | function render() { 194 | return ( 195 |
196 | <_Extension /> 197 |
Some Content
} 200 | /> 201 |
202 | ); 203 | } 204 | ``` 205 | 206 |
207 | 208 | #### insertAfter 209 | 210 | ##### Configuration 211 | 212 | ```js 213 | { 214 | "operation": "insertAfter", 215 | "targetContainer": "any.container.id", 216 | "targetChild": "container.child.id", 217 | "componentPath": "/Absolute/path/to/a/component.js" 218 | } 219 | ``` 220 | 221 |
222 | Example 223 | 224 | ##### Input 225 | 226 | ```js 227 | import React from 'react'; 228 | import { ContainerChild } from '@magento/peregrine'; 229 | 230 | function render() { 231 | return ( 232 |
233 |
Some Content
} 236 | /> 237 |
238 | ); 239 | } 240 | ``` 241 | 242 | ##### Output 243 | 244 | ```js 245 | import React from 'react'; 246 | import { ContainerChild } from '@magento/peregrine'; 247 | import _Extension from '/Absolute/path/to/a/component.js'; 248 | 249 | function render() { 250 | return ( 251 |
252 |
Some Content
} 255 | /> 256 | <_Extension /> 257 |
258 | ); 259 | } 260 | ``` 261 | 262 |
263 | 264 | ## FAQ 265 | 266 | ### How are the configurations for individual extensions/modules collected and provided to the loader? 267 | 268 | This functionality has not been completed yet. There is outstanding work to be 269 | done in Magento 2 to collect and expose aggregated configuration from each 270 | module. 271 | 272 | ### Why is a `Container` required to be a DOM Element? 273 | 274 | There are many tools in the React ecosystem that attempt to prevent passing 275 | unknown props to React components (`TypeScript`/`Flow`/`eslint`/etc). However, 276 | `data-*` props are always allowed on DOM elements. By enforcing this 277 | restriction, we can [prevent surprising 278 | behavior](https://en.wikipedia.org/wiki/Principle_of_least_astonishment) when 279 | copying and pasting code from examples into a theme or module/extension. 280 | Compiling out these extra props would not mitigate the issue, as all the 281 | aforementioned tools operate on source files. 282 | 283 | ### Why is a `ContainerChild` the only type of child allowed within a `Container`? 284 | 285 | This restriction is necessary to support the [`insertBefore`](#insertbefore) and 286 | [`insertAfter`](#insertafter) operations. Because a `ContainerChild` is required 287 | to have a unique `id` prop, this ensures that there will never be an 288 | untargetable child of a `Container`. 289 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@magento/pwa-buildpack", 3 | "version": "0.8.2", 4 | "description": "Build/Layout optimization tooling and Peregrine framework adapters for the Magento PWA", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "run-p jest:ci prettier:check lint", 8 | "test:dev": "run-s prettier lint jest:ci", 9 | "test:watch": "jest --watch", 10 | "test:debug": "node --inspect-brk ./node_modules/.bin/jest --runInBand", 11 | "test:watch:focus": "jest --watch --coverageReporters=text --collectCoverageFrom=\"${FOCUS}/**/*.js\" --no-cache \"${FOCUS}.*\"", 12 | "test:debug:focus": "node --inspect-brk ./node_modules/.bin/jest --runInBand --no-coverage --no-cache \"${FOCUS}.*\"", 13 | "build": "babel src --out-dir dist --ignore '__tests__/,__mocks__/,__fixtures__/' --source-maps --copy-files", 14 | "lint": "eslint '*.js' 'src/**/*.js'", 15 | "jest:ci": "jest -i --testResultsProcessor=./node_modules/jest-junit-reporter", 16 | "prettier": "prettier --write 'src/**/*.js'", 17 | "prettier:check": "prettier-check 'src/**/*.js'", 18 | "prepublishOnly": "rimraf dist && npm run build" 19 | }, 20 | "files": [ 21 | "dist", 22 | "buildpack-logo.icns" 23 | ], 24 | "engines": { 25 | "node": ">=8.0.0" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/magento-research/pwa-buildpack.git" 30 | }, 31 | "keywords": [ 32 | "magento", 33 | "pwa", 34 | "babel", 35 | "webpack" 36 | ], 37 | "author": "Magento Commerce", 38 | "license": "SEE LICENSE IN LICENSE.txt", 39 | "bugs": { 40 | "url": "https://github.com/magento-research/pwa-buildpack/issues" 41 | }, 42 | "homepage": "https://github.com/magento-research/pwa-buildpack#readme", 43 | "devDependencies": { 44 | "@magento/eslint-config": "^1.0.0", 45 | "babel-cli": "^6.26.0", 46 | "babel-core": "^6.26.0", 47 | "babel-eslint": "^8.2.1", 48 | "babel-loader": "^7.1.3", 49 | "babel-plugin-syntax-jsx": "^6.18.0", 50 | "babel-plugin-transform-class-properties": "^6.24.1", 51 | "babel-plugin-transform-react-jsx": "^6.24.1", 52 | "babel-preset-env": "^1.6.1", 53 | "dedent": "^0.7.0", 54 | "eslint": "^4.16.0", 55 | "eslint-plugin-babel": "^4.1.2", 56 | "eslint-plugin-jsx-a11y": "^6.0.3", 57 | "eslint-plugin-node": "^6.0.0", 58 | "eslint-plugin-react": "^7.5.1", 59 | "jest": "^22.4.0", 60 | "jest-junit-reporter": "^1.1.0", 61 | "memory-fs": "^0.4.1", 62 | "nock": "^9.2.5", 63 | "npm-run-all": "^4.1.2", 64 | "prettier": "^1.8.1", 65 | "prettier-check": "^2.0.0", 66 | "react": "^16.1.1", 67 | "rimraf": "^2.6.2", 68 | "supertest": "^3.0.0", 69 | "webpack": "^3.10.0" 70 | }, 71 | "dependencies": { 72 | "@magento/devcert": "^0.7.0", 73 | "@magento/directive-parser": "^0.1.1", 74 | "ajv": "^6.1.1", 75 | "babel-helper-module-imports": "^7.0.0-beta.3", 76 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 77 | "debug": "^3.1.0", 78 | "express": "^4.16.2", 79 | "flat-file-db": "^1.0.0", 80 | "harmon": "^1.3.2", 81 | "hostile": "^1.3.1", 82 | "http-proxy-middleware": "^0.18.0", 83 | "lodash.get": "^4.4.2", 84 | "openport": "0.0.4", 85 | "through": "^2.3.8", 86 | "webpack-sources": "^1.1.0", 87 | "workbox-webpack-plugin": "^3.0.0-beta.1", 88 | "write-file-webpack-plugin": "^4.2.0" 89 | }, 90 | "peerDependencies": { 91 | "babel-core": "^6.26.0", 92 | "babel-loader": "^7" 93 | }, 94 | "jest": { 95 | "clearMocks": true, 96 | "collectCoverage": true, 97 | "collectCoverageFrom": [ 98 | "**/src/**/*.js" 99 | ], 100 | "coverageReporters": [ 101 | "lcov", 102 | "text-summary" 103 | ], 104 | "testEnvironment": "node", 105 | "testPathIgnorePatterns": [ 106 | "dist", 107 | "node_modules", 108 | "__fixtures__" 109 | ] 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/ExtensionComponentWrap/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "last 2 versions", 9 | "ie 11" 10 | ] 11 | } 12 | } 13 | ] 14 | ], 15 | "plugins": [ 16 | "transform-react-jsx" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/ExtensionComponentWrap/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | sourceType: 'module' 4 | }, 5 | rules: { 6 | // All other code in this project is expected to run in Node. 7 | // This comes from the extended "node:recommended" configuration. 8 | // ESLint does not allow overrides to remove "extends", so 9 | // we instead disable this individual rule for this file. 10 | // Remove when moving ExtensionComponentWrap to Peregrine. 11 | 'node/no-unsupported-features': 'off' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/ExtensionComponentWrap/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Note: This component should be moved to `peregrine` repository. 3 | * It lives in this repo currently because this package consumes it, and we 4 | * don't have private npm packages yet 5 | */ 6 | 7 | import React from 'react'; 8 | 9 | const errorStyles = { 10 | background: '#ff1b1b', 11 | color: '#fff', 12 | fontWeight: 'bold', 13 | fontSize: '1em', 14 | fontFamily: 'sans-serif' 15 | }; 16 | 17 | export default class MagentoExtensionBoundary extends React.Component { 18 | constructor() { 19 | super(); 20 | this.state = { 21 | hasError: false 22 | }; 23 | } 24 | 25 | componentDidCatch() { 26 | const { replacedID } = this.props; 27 | console.error( 28 | 'An error occurred within a part of the React component tree ' + 29 | 'created by a Magento Extension. Look for the component registered ' + 30 | `to replace the mid "%c${replacedID}%c" to debug further.` 31 | ); 32 | this.setState({ hasError: true }); 33 | } 34 | 35 | render() { 36 | if (!this.state.hasError) { 37 | return this.props.children; 38 | } 39 | 40 | return ( 41 |
42 | An error occurred within a Magento Extension. See the browser's 43 | JavaScript console for further details. 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/WebpackTools/MagentoResolver.js: -------------------------------------------------------------------------------- 1 | const optionsValidator = require('../util/options-validator'); 2 | const validateConfig = optionsValidator('MagentoResolver', { 3 | 'paths.root': 'string' 4 | }); 5 | module.exports = { 6 | validateConfig, 7 | async configure(options) { 8 | validateConfig('.configure()', options); 9 | return { 10 | modules: [options.paths.root, 'node_modules'], 11 | mainFiles: ['index'], 12 | extensions: ['.js', '.json'] 13 | }; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/WebpackTools/PWADevServer.js: -------------------------------------------------------------------------------- 1 | const debug = require('../util/debug').makeFileLogger(__filename); 2 | const { join } = require('path'); 3 | const url = require('url'); 4 | const express = require('express'); 5 | const GlobalConfig = require('../util/global-config'); 6 | const SSLCertStore = require('../util/ssl-cert-store'); 7 | const optionsValidator = require('../util/options-validator'); 8 | const middlewares = { 9 | originSubstitution: require('./middlewares/OriginSubstitution'), 10 | devProxy: require('./middlewares/DevProxy'), 11 | staticRootRoute: require('./middlewares/StaticRootRoute') 12 | }; 13 | const { lookup } = require('../util/promisified/dns'); 14 | const { find: findPort } = require('../util/promisified/openport'); 15 | const runAsRoot = require('../util/run-as-root'); 16 | const PWADevServer = { 17 | validateConfig: optionsValidator('PWADevServer', { 18 | id: 'string', 19 | publicPath: 'string', 20 | backendDomain: 'string', 21 | 'paths.output': 'string', 22 | 'paths.assets': 'string', 23 | serviceWorkerFileName: 'string' 24 | }), 25 | hostnamesById: new GlobalConfig({ 26 | prefix: 'devhostname-byid', 27 | key: x => x 28 | }), 29 | portsByHostname: new GlobalConfig({ 30 | prefix: 'devport-byhostname', 31 | key: x => x 32 | }), 33 | async setLoopback(hostname) { 34 | debug(`checking if ${hostname} is loopback`); 35 | let ip; 36 | try { 37 | ip = await lookup(hostname); 38 | } catch (e) { 39 | if (e.code !== 'ENOTFOUND') { 40 | throw Error( 41 | debug.errorMsg( 42 | `Error trying to check that ${hostname} is loopback: ${ 43 | e.message 44 | }` 45 | ) 46 | ); 47 | } 48 | } 49 | if (ip && (ip.address === '127.0.0.1' || ip.address === '::1')) { 50 | debug(`${hostname} already resolves to ${ip.address}!`); 51 | } else { 52 | debug( 53 | `setting ${hostname} loopback in /etc/hosts, may require password...` 54 | ); 55 | return runAsRoot( 56 | `Resolving ${hostname} to localhost and editing the hostfile requires temporary administrative privileges.\n Enter password for %u on %H: `, 57 | /* istanbul ignore next: never runs in process */ 58 | d => require('hostile').set('127.0.0.1', d), 59 | hostname 60 | ); 61 | } 62 | }, 63 | async findFreePort() { 64 | const inUse = await PWADevServer.portsByHostname.values(Number); 65 | debug(`findFreePort(): these ports already in use`, inUse); 66 | return findPort({ 67 | startingPort: 8000, 68 | endingPort: 9999, 69 | avoid: inUse 70 | }).catch(e => { 71 | throw Error( 72 | debug.errorMsg( 73 | `Unable to find an open port. You may want to delete your database file at ${GlobalConfig.getDbFilePath()} to clear out old developer hostname entries. (Soon we will make this easier and more automatic.) Original error: ${e.toString()}` 74 | ) 75 | ); 76 | }); 77 | }, 78 | async findFreeHostname(identifier, times = 0) { 79 | const maybeHostname = 80 | identifier + (times ? times : '') + '.local.pwadev'; 81 | // if it has a port, it exists 82 | const exists = await PWADevServer.portsByHostname.get(maybeHostname); 83 | if (!exists) { 84 | debug( 85 | `findFreeHostname: ${maybeHostname} unbound to port and available` 86 | ); 87 | return maybeHostname; 88 | } else { 89 | debug(`findFreeHostname: ${maybeHostname} bound to port`, exists); 90 | if (times > 9) { 91 | throw Error( 92 | debug.errorMsg( 93 | `findFreeHostname: Unable to find a free hostname after 9 tries. You may want to delete your database file at ${GlobalConfig.getDbFilePath()} to clear out old developer hostname entries. (Soon we will make this easier and more automatic.)` 94 | ) 95 | ); 96 | } 97 | return PWADevServer.findFreeHostname(identifier, times + 1); 98 | } 99 | }, 100 | async provideDevHost(id) { 101 | debug(`provideDevHost('${id}')`); 102 | let hostname = await PWADevServer.hostnamesById.get(id); 103 | let port; 104 | if (!hostname) { 105 | [hostname, port] = await Promise.all([ 106 | PWADevServer.findFreeHostname(id), 107 | PWADevServer.findFreePort() 108 | ]); 109 | 110 | await PWADevServer.hostnamesById.set(id, hostname); 111 | await PWADevServer.portsByHostname.set(hostname, port); 112 | } else { 113 | port = await PWADevServer.portsByHostname.get(hostname); 114 | if (!port) { 115 | throw Error( 116 | debug.errorMsg( 117 | `Found no port matching the hostname ${hostname}` 118 | ) 119 | ); 120 | } 121 | } 122 | PWADevServer.setLoopback(hostname); 123 | return { 124 | protocol: 'https:', 125 | hostname, 126 | port 127 | }; 128 | }, 129 | async configure(config = {}) { 130 | debug('configure() invoked', config); 131 | PWADevServer.validateConfig('.configure(config)', config); 132 | const sanitizedId = config.id 133 | .toLowerCase() 134 | .replace(/[^a-zA-Z0-9]/g, '-') 135 | .replace(/^-+/, ''); 136 | const devHost = await PWADevServer.provideDevHost(sanitizedId); 137 | const https = await SSLCertStore.provide(devHost.hostname); 138 | debug(`https provided:`, https); 139 | return { 140 | contentBase: false, 141 | compress: true, 142 | hot: true, 143 | https, 144 | host: devHost.hostname, 145 | port: devHost.port, 146 | publicPath: url.format( 147 | Object.assign({}, devHost, { pathname: config.publicPath }) 148 | ), 149 | before(app) { 150 | if (config.changeOrigin) { 151 | // replace origins in links in returned html 152 | app.use( 153 | middlewares.originSubstitution( 154 | new url.URL(config.backendDomain), 155 | devHost 156 | ) 157 | ); 158 | } 159 | // serviceworker root route 160 | app.use( 161 | middlewares.staticRootRoute( 162 | join(config.paths.output, config.serviceWorkerFileName) 163 | ) 164 | ); 165 | }, 166 | after(app) { 167 | // set static server to load and serve from different paths 168 | app.use(config.publicPath, express.static(config.paths.assets)); 169 | 170 | // proxy to backend 171 | app.use( 172 | middlewares.devProxy({ 173 | target: config.backendDomain 174 | }) 175 | ); 176 | } 177 | }; 178 | } 179 | }; 180 | module.exports = PWADevServer; 181 | -------------------------------------------------------------------------------- /src/WebpackTools/__tests__/MagentoResolver.spec.js: -------------------------------------------------------------------------------- 1 | const MagentoResolver = require('../MagentoResolver'); 2 | test('static configure() produces a webpack resolver config', async () => { 3 | await expect( 4 | MagentoResolver.configure({ paths: { root: 'fakeRoot' } }) 5 | ).resolves.toEqual({ 6 | modules: ['fakeRoot', 'node_modules'], 7 | mainFiles: ['index'], 8 | extensions: ['.js', '.json'] 9 | }); 10 | }); 11 | test('static configure() throws if required paths are missing', async () => { 12 | await expect( 13 | MagentoResolver.configure({ paths: { root: false } }) 14 | ).rejects.toThrow('paths.root must be of type string'); 15 | }); 16 | -------------------------------------------------------------------------------- /src/WebpackTools/__tests__/PWADevServer.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../util/promisified/dns'); 2 | jest.mock('../../util/promisified/openport'); 3 | jest.mock('../../util/global-config'); 4 | jest.mock('../../util/ssl-cert-store'); 5 | jest.mock('../../util/run-as-root'); 6 | jest.mock('../middlewares/DevProxy'); 7 | jest.mock('../middlewares/OriginSubstitution'); 8 | jest.mock('../middlewares/StaticRootRoute'); 9 | 10 | const { lookup } = require('../../util/promisified/dns'); 11 | const openport = require('../../util/promisified/openport'); 12 | const runAsRoot = require('../../util/run-as-root'); 13 | const GlobalConfig = require('../../util/global-config'); 14 | const SSLCertStore = require('../../util/ssl-cert-store'); 15 | const middlewares = { 16 | DevProxy: require('../middlewares/DevProxy'), 17 | OriginSubstitution: require('../middlewares/OriginSubstitution'), 18 | StaticRootRoute: require('../middlewares/StaticRootRoute') 19 | }; 20 | 21 | let PWADevServer; 22 | beforeAll(() => { 23 | GlobalConfig.mockImplementation(({ key }) => ({ 24 | set: jest.fn(key), 25 | get: jest.fn(), 26 | values: jest.fn() 27 | })); 28 | PWADevServer = require('../').PWADevServer; 29 | }); 30 | 31 | const simulate = { 32 | hostResolvesLoopback({ family = 4 } = {}) { 33 | lookup.mockReturnValueOnce({ 34 | address: family === 6 ? '::1' : '127.0.0.1', 35 | family 36 | }); 37 | return simulate; 38 | }, 39 | hostDoesNotResolve() { 40 | lookup.mockRejectedValueOnce({ code: 'ENOTFOUND' }); 41 | return simulate; 42 | }, 43 | hostnameForNextId(name) { 44 | PWADevServer.hostnamesById.get.mockReturnValueOnce(name); 45 | return simulate; 46 | }, 47 | noHostnameForNextId() { 48 | PWADevServer.hostnamesById.get.mockReturnValueOnce(undefined); 49 | return simulate; 50 | }, 51 | noPortSavedForNextHostname() { 52 | PWADevServer.portsByHostname.get.mockReturnValueOnce(undefined); 53 | return simulate; 54 | }, 55 | portSavedForNextHostname(n = 8000) { 56 | PWADevServer.portsByHostname.get.mockReturnValueOnce(n); 57 | return simulate; 58 | }, 59 | savedPortsAre(...ports) { 60 | PWADevServer.portsByHostname.values.mockReturnValueOnce(ports); 61 | return simulate; 62 | }, 63 | aFreePortWasFound(n = 8000) { 64 | openport.find.mockResolvedValueOnce(n); 65 | return simulate; 66 | }, 67 | certExistsForNextHostname(pair) { 68 | SSLCertStore.provide.mockResolvedValueOnce(pair); 69 | } 70 | }; 71 | 72 | test('.setLoopback() checks if hostname resolves local, ipv4 or 6', async () => { 73 | simulate.hostResolvesLoopback(); 74 | await PWADevServer.setLoopback('excelsior.com'); 75 | expect(lookup).toHaveBeenCalledWith('excelsior.com'); 76 | expect(runAsRoot).not.toHaveBeenCalled(); 77 | 78 | simulate.hostResolvesLoopback({ family: 6 }); 79 | await PWADevServer.setLoopback('excelsior.com'); 80 | expect(runAsRoot).not.toHaveBeenCalled(); 81 | }); 82 | 83 | test('.setLoopback() updates /etc/hosts to make hostname local', async () => { 84 | lookup.mockRejectedValueOnce({ code: 'ENOTFOUND' }); 85 | await PWADevServer.setLoopback('excelsior.com'); 86 | expect(runAsRoot).toHaveBeenCalledWith( 87 | expect.any(String), 88 | expect.any(Function), 89 | 'excelsior.com' 90 | ); 91 | }); 92 | 93 | test('.setLoopback() dies under mysterious circumstances', async () => { 94 | lookup.mockRejectedValueOnce({ code: 'UNKNOWN' }); 95 | await expect(PWADevServer.setLoopback('excelsior.com')).rejects.toThrow( 96 | 'Error trying to check' 97 | ); 98 | }); 99 | 100 | test('.findFreePort() uses openPort to get a free port', async () => { 101 | simulate.savedPortsAre(8543, 9002, 8765).aFreePortWasFound(); 102 | 103 | await PWADevServer.findFreePort(); 104 | expect(openport.find).toHaveBeenCalledWith( 105 | expect.objectContaining({ 106 | avoid: expect.arrayContaining([8543, 9002, 8765]) 107 | }) 108 | ); 109 | }); 110 | 111 | test('.findFreePort() passes formatted errors from port lookup', async () => { 112 | openport.find.mockRejectedValueOnce('woah'); 113 | 114 | await expect(PWADevServer.findFreePort()).rejects.toThrowError( 115 | /Unable to find an open port.*woah/ 116 | ); 117 | }); 118 | 119 | test('.findFreeHostname() makes a new hostname for an identifier', async () => { 120 | simulate.noPortSavedForNextHostname(); 121 | const hostname = await PWADevServer.findFreeHostname('bar'); 122 | expect(hostname).toBe('bar.local.pwadev'); 123 | }); 124 | 125 | test('.findFreeHostname() skips past taken hostnames for an identifier', async () => { 126 | const hostname = await PWADevServer.findFreeHostname('foo'); 127 | expect(hostname).toBe('foo.local.pwadev'); 128 | 129 | simulate 130 | .portSavedForNextHostname() 131 | .portSavedForNextHostname() 132 | .portSavedForNextHostname() 133 | .noPortSavedForNextHostname(); 134 | 135 | const hostname2 = await PWADevServer.findFreeHostname('foo'); 136 | expect(hostname2).toBe('foo3.local.pwadev'); 137 | }); 138 | 139 | test('.findFreeHostname() bails after 9 failed attempts', async () => { 140 | const hostname = await PWADevServer.findFreeHostname('foo'); 141 | expect(hostname).toBe('foo.local.pwadev'); 142 | 143 | simulate 144 | .portSavedForNextHostname() 145 | .portSavedForNextHostname() 146 | .portSavedForNextHostname() 147 | .portSavedForNextHostname() 148 | .portSavedForNextHostname() 149 | .portSavedForNextHostname() 150 | .portSavedForNextHostname() 151 | .portSavedForNextHostname() 152 | .portSavedForNextHostname() 153 | .portSavedForNextHostname() 154 | .portSavedForNextHostname(); 155 | 156 | await expect(PWADevServer.findFreeHostname('foo')).rejects.toThrowError( 157 | `Unable to find a free hostname after` 158 | ); 159 | }); 160 | 161 | test('.provideDevHost() returns a URL object with a free dev host origin', async () => { 162 | simulate 163 | .noHostnameForNextId() 164 | .noPortSavedForNextHostname() 165 | .aFreePortWasFound(8765) 166 | .hostDoesNotResolve(); 167 | 168 | await expect(PWADevServer.provideDevHost('woah')).resolves.toMatchObject({ 169 | protocol: 'https:', 170 | hostname: 'woah.local.pwadev', 171 | port: 8765 172 | }); 173 | }); 174 | 175 | test('.provideDevHost() returns a URL object with a cached dev host origin', async () => { 176 | simulate 177 | .hostnameForNextId('cached-host.local.pwadev') 178 | .portSavedForNextHostname(8765) 179 | .hostResolvesLoopback(); 180 | 181 | await expect(PWADevServer.provideDevHost('wat')).resolves.toMatchObject({ 182 | protocol: 'https:', 183 | hostname: 'cached-host.local.pwadev', 184 | port: 8765 185 | }); 186 | }); 187 | 188 | test('.provideDevHost() throws if it got a reserved hostname but could not find a port for that hostname', async () => { 189 | simulate 190 | .hostnameForNextId('doomed-host.local.pwadev') 191 | .noPortSavedForNextHostname(); 192 | 193 | await expect(PWADevServer.provideDevHost('dang')).rejects.toThrow( 194 | 'Found no port matching the hostname' 195 | ); 196 | }); 197 | 198 | test('.configure() throws errors on missing config', async () => { 199 | await expect(PWADevServer.configure()).rejects.toThrow( 200 | 'id must be of type string' 201 | ); 202 | await expect(PWADevServer.configure({ id: 'foo' })).rejects.toThrow( 203 | 'publicPath must be of type string' 204 | ); 205 | await expect( 206 | PWADevServer.configure({ id: 'foo', publicPath: 'bar' }) 207 | ).rejects.toThrow('backendDomain must be of type string'); 208 | await expect( 209 | PWADevServer.configure({ 210 | id: 'foo', 211 | publicPath: 'bar', 212 | backendDomain: 'https://dumb.domain', 213 | paths: {} 214 | }) 215 | ).rejects.toThrow('paths.output must be of type string'); 216 | await expect( 217 | PWADevServer.configure({ 218 | id: 'foo', 219 | publicPath: 'bar', 220 | backendDomain: 'https://dumb.domain', 221 | paths: { output: 'output' } 222 | }) 223 | ).rejects.toThrow('paths.assets must be of type string'); 224 | await expect( 225 | PWADevServer.configure({ 226 | id: 'foo', 227 | publicPath: 'bar', 228 | backendDomain: 'https://dumb.domain', 229 | paths: { output: 'foo', assets: 'bar' } 230 | }) 231 | ).rejects.toThrow('serviceWorkerFileName must be of type string'); 232 | }); 233 | 234 | test('.configure() gets or creates an SSL cert', async () => { 235 | simulate 236 | .hostnameForNextId('coolnewhost.local.pwadev') 237 | .portSavedForNextHostname(8765) 238 | .hostResolvesLoopback() 239 | .certExistsForNextHostname({ 240 | key: 'fakeKey', 241 | cert: 'fakeCert' 242 | }); 243 | const server = await PWADevServer.configure({ 244 | id: 'heckin', 245 | paths: { 246 | output: 'good', 247 | assets: 'boye' 248 | }, 249 | publicPath: 'bork', 250 | serviceWorkerFileName: 'doin', 251 | backendDomain: 'growe' 252 | }); 253 | expect(SSLCertStore.provide).toHaveBeenCalled(); 254 | expect(server.https).toHaveProperty('cert', 'fakeCert'); 255 | }); 256 | 257 | test('.configure() returns a configuration object for the `devServer` property of a webpack config', async () => { 258 | simulate 259 | .hostnameForNextId('coolnewhost.local.pwadev') 260 | .portSavedForNextHostname(8765) 261 | .hostResolvesLoopback() 262 | .certExistsForNextHostname({ 263 | key: 'fakeKey2', 264 | cert: 'fakeCert2' 265 | }); 266 | 267 | const config = { 268 | id: 'Theme_Unique_Id', 269 | paths: { 270 | output: 'path/to/static', 271 | assets: 'path/to/assets' 272 | }, 273 | publicPath: 'full/path/to/publicPath', 274 | serviceWorkerFileName: 'swname.js', 275 | backendDomain: 'https://magento.backend.domain' 276 | }; 277 | 278 | const devServer = await PWADevServer.configure(config); 279 | 280 | expect(devServer).toMatchObject({ 281 | contentBase: false, 282 | compress: true, 283 | hot: true, 284 | https: { 285 | key: 'fakeKey2', 286 | cert: 'fakeCert2' 287 | }, 288 | host: 'coolnewhost.local.pwadev', 289 | port: 8765, 290 | publicPath: 291 | 'https://coolnewhost.local.pwadev:8765/full/path/to/publicPath', 292 | before: expect.any(Function), 293 | after: expect.any(Function) 294 | }); 295 | }); 296 | 297 | test('.configure() returns a configuration object with before() and after() handlers that add middlewares in order', async () => { 298 | simulate 299 | .hostnameForNextId('coolnewhost.local.pwadev') 300 | .portSavedForNextHostname(8765) 301 | .hostResolvesLoopback() 302 | .certExistsForNextHostname({ 303 | key: 'fakeKey2', 304 | cert: 'fakeCert2' 305 | }); 306 | 307 | const config = { 308 | id: 'Theme_Unique_Id', 309 | paths: { 310 | output: 'path/to/static', 311 | assets: 'path/to/assets' 312 | }, 313 | publicPath: 'full/path/to/publicPath', 314 | serviceWorkerFileName: 'swname.js', 315 | backendDomain: 'https://magento.backend.domain' 316 | }; 317 | 318 | const devServer = await PWADevServer.configure(config); 319 | 320 | const app = { 321 | use: jest.fn() 322 | }; 323 | 324 | middlewares.StaticRootRoute.mockReturnValueOnce('fakeStaticRootRoute'); 325 | 326 | devServer.before(app); 327 | 328 | middlewares.DevProxy.mockReturnValueOnce('fakeDevProxy'); 329 | 330 | devServer.after(app); 331 | 332 | expect(middlewares.DevProxy).toHaveBeenCalledWith( 333 | expect.objectContaining({ 334 | target: 'https://magento.backend.domain' 335 | }) 336 | ); 337 | 338 | expect(middlewares.OriginSubstitution).not.toHaveBeenCalled(); 339 | 340 | expect(app.use).toHaveBeenCalledWith('fakeDevProxy'); 341 | 342 | expect(middlewares.StaticRootRoute).toHaveBeenCalledWith( 343 | 'path/to/static/swname.js' 344 | ); 345 | 346 | expect(app.use).toHaveBeenCalledWith('fakeStaticRootRoute'); 347 | 348 | expect(app.use).toHaveBeenCalledWith( 349 | 'full/path/to/publicPath', 350 | expect.any(Function) 351 | ); 352 | }); 353 | 354 | test('.configure() optionally adds OriginSubstitution middleware', async () => { 355 | simulate 356 | .hostnameForNextId('coolnewhost.local.pwadev') 357 | .portSavedForNextHostname(8765) 358 | .hostResolvesLoopback() 359 | .certExistsForNextHostname({ 360 | key: 'fakeKey2', 361 | cert: 'fakeCert2' 362 | }); 363 | 364 | const config = { 365 | id: 'Theme_Unique_Id', 366 | paths: { 367 | output: 'path/to/static', 368 | assets: 'path/to/assets' 369 | }, 370 | publicPath: 'full/path/to/publicPath', 371 | serviceWorkerFileName: 'swname.js', 372 | backendDomain: 'https://magento.backend.domain', 373 | changeOrigin: true 374 | }; 375 | 376 | const devServer = await PWADevServer.configure(config); 377 | 378 | const app = { 379 | use: jest.fn() 380 | }; 381 | 382 | middlewares.OriginSubstitution.mockReturnValueOnce( 383 | 'fakeOriginSubstitution' 384 | ); 385 | middlewares.DevProxy.mockReturnValueOnce('fakeDevProxy'); 386 | middlewares.StaticRootRoute.mockReturnValueOnce('fakeStaticRootRoute'); 387 | 388 | devServer.before(app); 389 | 390 | expect(middlewares.OriginSubstitution).toHaveBeenCalledWith( 391 | expect.objectContaining({ 392 | protocol: 'https:', 393 | hostname: 'magento.backend.domain' 394 | }), 395 | expect.objectContaining({ 396 | protocol: 'https:', 397 | hostname: 'coolnewhost.local.pwadev', 398 | port: 8765 399 | }) 400 | ); 401 | 402 | expect(app.use).toHaveBeenCalledWith('fakeOriginSubstitution'); 403 | }); 404 | -------------------------------------------------------------------------------- /src/WebpackTools/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | MagentoRootComponentsPlugin: require('./plugins/MagentoRootComponentsPlugin'), 3 | ServiceWorkerPlugin: require('./plugins/ServiceWorkerPlugin'), 4 | MagentoResolver: require('./MagentoResolver'), 5 | PWADevServer: require('./PWADevServer') 6 | }; 7 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/DevProxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Proxies all requests not served by Webpack in-memory bundling back 3 | * to the underlying store. 4 | * 5 | * Tries to detect a case where the target URL is misconfigured and the 6 | * store tries to redirect to http or https. 7 | */ 8 | const proxyMiddleware = require('http-proxy-middleware'); 9 | const optionsValidator = require('../../util/options-validator'); 10 | const { format, URL } = require('url'); 11 | 12 | const RedirectCodes = [201, 301, 302, 307, 308]; 13 | const findRedirect = message => 14 | RedirectCodes.includes(message.statusCode) && message.headers.location; 15 | 16 | const emitErrorOnProtocolChange = (emit, target, redirected) => { 17 | const backend = new URL(target); 18 | const { host, protocol } = new URL(redirected); 19 | if (backend.host === host && backend.protocol === protocol) { 20 | return; 21 | } 22 | 23 | if (backend.protocol === 'https:' && protocol === 'http:') { 24 | return emit( 25 | new Error( 26 | `pwa-buildpack: Backend domain is configured to ${target}, but redirected to unsecure HTTP. Please configure backend server to use SSL.` 27 | ) 28 | ); 29 | } 30 | 31 | if (backend.protocol === 'http:' && protocol === 'https:') { 32 | return emit( 33 | new Error( 34 | `pwa-buildpack: Backend domain is configured to ${target}, but redirected to secure HTTPS. Please change configuration to point to secure backend domain: ${format( 35 | Object.assign(backend, { protocol: 'https:' }) 36 | )}.` 37 | ) 38 | ); 39 | } 40 | 41 | emit( 42 | new Error( 43 | `pwa-buildpack: Backend domain redirected to unknown protocol: ${redirected}` 44 | ) 45 | ); 46 | }; 47 | 48 | const validateConfig = optionsValidator('DevProxyMiddleware', { 49 | target: 'string' 50 | }); 51 | 52 | module.exports = function createDevProxy(config) { 53 | validateConfig('createDevProxy', config); 54 | const proxyConf = Object.assign( 55 | { 56 | logLevel: 'debug', 57 | logProvider: defaultProvider => config.logger || defaultProvider, 58 | onProxyRes(proxyRes) { 59 | const redirected = findRedirect(proxyRes); 60 | if (redirected) { 61 | emitErrorOnProtocolChange( 62 | nextCallback, 63 | config.target, 64 | redirected 65 | ); 66 | } 67 | }, 68 | secure: false, 69 | changeOrigin: true, 70 | autoRewrite: true, 71 | cookieDomainRewrite: '' // remove any absolute domain on cookies 72 | }, 73 | config 74 | ); 75 | let nextCallback; 76 | const proxy = proxyMiddleware('**', proxyConf); 77 | // Return an outer middleware so we can access the `next` function to 78 | // properly pass errors along. 79 | return (req, res, next) => { 80 | nextCallback = err => { 81 | proxyConf.logProvider(console).error(err); 82 | return next(err); 83 | }; 84 | return proxy(req, res, next); 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/OriginSubstitution.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replace all instances of one URL base in an HTML response, with another. 3 | * Useful for proxying to systems like Magento, which often generate absolute 4 | * URLs in their render output. 5 | * 6 | * Rather than configure Magento to use the your temporary dev server URL as 7 | * its configured base domain, this middleware allows the dev server to text 8 | * replace any links, resources, or reference URLs on the fly. 9 | * 10 | * For Magento 2 specifically, This is a stopgap until we can hack Framework to 11 | * have branch logic in asset URL resolvers. 12 | * 13 | * EXPERIMENTAL -- not ready for prime time 14 | */ 15 | const debug = require('../../util/debug').makeFileLogger(__filename); 16 | const url = require('url'); 17 | const harmon = require('harmon'); 18 | const through = require('through'); 19 | const removeTrailingSlash = x => x.replace(/\/$/, ''); 20 | module.exports = function createOriginSubstitutionMiddleware( 21 | oldDomain, 22 | newDomain 23 | ) { 24 | const oldOrigin = removeTrailingSlash(url.format(oldDomain)); 25 | const newOrigin = removeTrailingSlash(url.format(newDomain)); 26 | const attributesToReplaceOrigin = ['href', 'src', 'style'].map(attr => ({ 27 | query: `[${attr}*="${oldOrigin}"]`, 28 | func(node) { 29 | node.setAttribute( 30 | attr, 31 | node 32 | .getAttribute(attr) 33 | .split(oldOrigin) 34 | .join(newOrigin) 35 | ); 36 | } 37 | })); 38 | const tagsToReplaceOrigin = ['style'].map(attr => ({ 39 | query: attr, 40 | func(node) { 41 | debug('tag', attr, node); 42 | const stream = node.createStream(); 43 | stream 44 | .pipe( 45 | through(function(buf) { 46 | this.queue( 47 | buf 48 | .toString() 49 | .split(oldOrigin) 50 | .join(newOrigin) 51 | ); 52 | }) 53 | ) 54 | .pipe(stream); 55 | } 56 | })); 57 | debug( 58 | `replace ${oldOrigin} with ${newOrigin} in html`, 59 | attributesToReplaceOrigin 60 | ); 61 | const allTransforms = [ 62 | ...tagsToReplaceOrigin, 63 | ...attributesToReplaceOrigin 64 | ]; 65 | return harmon([], allTransforms, true); 66 | }; 67 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/StaticRootRoute.js: -------------------------------------------------------------------------------- 1 | const { basename } = require('path'); 2 | module.exports = function createStaticRootRoute(filepath) { 3 | const publicPath = '/' + basename(filepath); 4 | return (req, res, next) => { 5 | if (req.method === 'GET' && req.path === publicPath) { 6 | res.sendFile(filepath); 7 | } else { 8 | next(); 9 | } 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/__fixtures__/root-available-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "goodJsonResponse": true 3 | } 4 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/__tests__/DevProxy.spec.js: -------------------------------------------------------------------------------- 1 | let devProxy; 2 | const request = require('supertest'); 3 | const nock = require('nock'); 4 | const express = require('express'); 5 | 6 | const TARGET = 'https://proxytarget.test'; 7 | const TARGET_UNSECURE = 'http://proxytarget.test'; 8 | const logger = {}; 9 | const logMethods = ['log', 'debug', 'info', 'warn', 'error']; 10 | 11 | beforeAll(() => { 12 | logMethods.forEach(method => { 13 | logger[method] = jest.fn(); 14 | jest.spyOn(console, method).mockImplementation(); 15 | }); 16 | // We wait to require DevProxy here because DevProxy imports `http-proxy-middleware`, and 17 | // `http-proxy-middleware` creates a "default logger" at module definition time--that is, when 18 | // we require() it. The default logger destructures the console object, so it no longer holds 19 | // a reference to `console` itself. This makes us unable to mock `console` for the 20 | // `http-proxy-middleware` library if it loads before the mock does. 21 | devProxy = require('../DevProxy'); 22 | }); 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | afterAll(() => { 29 | logMethods.forEach(method => { 30 | console[method].mockRestore(); 31 | }); 32 | nock.restore(); 33 | }); 34 | 35 | test('logs to custom logger', async () => { 36 | nock(TARGET) 37 | .get('/will-log') 38 | .reply(200, 'Hello world!'); 39 | 40 | const appWithCustomLogger = express(); 41 | appWithCustomLogger.use( 42 | devProxy({ 43 | logger, 44 | target: TARGET 45 | }) 46 | ); 47 | 48 | await request(appWithCustomLogger).get('/will-log'); 49 | 50 | expect(logger.debug).toHaveBeenCalledWith( 51 | expect.stringContaining('will-log') 52 | ); 53 | }); 54 | 55 | test('logs to console by default', async () => { 56 | nock(TARGET) 57 | .get('/will-log') 58 | .reply(200, 'Hello world!'); 59 | const app = express(); 60 | app.use( 61 | devProxy({ 62 | target: TARGET 63 | }) 64 | ); 65 | 66 | await request(app).get('/will-log'); 67 | 68 | expect(console.log).toHaveBeenCalledWith( 69 | expect.stringContaining('will-log') 70 | ); 71 | 72 | console.log.mockRestore(); 73 | }); 74 | 75 | test('handles redirects silently when origin is same', async () => { 76 | nock(TARGET) 77 | .get('/will-proxy-to-self') 78 | .reply(301, '', { 79 | Location: TARGET + '/redirected-to' 80 | }); 81 | 82 | const app = express(); 83 | app.use( 84 | devProxy({ 85 | logger, 86 | target: TARGET 87 | }) 88 | ); 89 | 90 | await expect( 91 | request(app) 92 | .get('/will-proxy-to-self') 93 | .expect(301) 94 | .expect('location', /redirected\-to/) 95 | ).resolves.toBeTruthy(); 96 | }); 97 | 98 | test('errors informatively on redirect with protocol change', async () => { 99 | nock(TARGET_UNSECURE) 100 | .get('/will-proxy-to-secure') 101 | .reply(301, '', { 102 | Location: TARGET + '/will-proxy-to-secure' 103 | }); 104 | 105 | const app = express(); 106 | app.use( 107 | devProxy({ 108 | logger, 109 | target: TARGET_UNSECURE 110 | }) 111 | ); 112 | 113 | await expect( 114 | request(app).get('/will-proxy-to-secure') 115 | ).resolves.toMatchObject({ 116 | status: 500, 117 | text: expect.stringContaining('redirected to secure HTTPS') 118 | }); 119 | 120 | nock(TARGET) 121 | .get('/will-proxy-to-unsecure') 122 | .reply(301, '', { 123 | Location: TARGET_UNSECURE + '/will-proxy-to-unsecure' 124 | }) 125 | .get('/will-proxy-to-nowhere') 126 | .reply(302, '', { 127 | Location: 'badprotocol' + TARGET + '/will-proxy-to-nowhere' 128 | }); 129 | 130 | const secureApp = express(); 131 | secureApp.use( 132 | devProxy({ 133 | logger, 134 | target: TARGET 135 | }) 136 | ); 137 | 138 | await expect( 139 | request(secureApp).get('/will-proxy-to-unsecure') 140 | ).resolves.toMatchObject({ 141 | status: 500, 142 | text: expect.stringContaining('redirected to unsecure HTTP') 143 | }); 144 | 145 | await expect( 146 | request(secureApp).get('/will-proxy-to-nowhere') 147 | ).resolves.toMatchObject({ 148 | status: 500, 149 | text: expect.stringContaining('redirected to unknown protocol') 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/__tests__/OriginSubstitution.spec.js: -------------------------------------------------------------------------------- 1 | const { URL } = require('url'); 2 | const request = require('supertest'); 3 | const express = require('express'); 4 | 5 | const originSubstitution = require('../OriginSubstitution'); 6 | 7 | test('swaps origins in html', async () => { 8 | const app = express(); 9 | const backendUrl = new URL('https://old.backend:8080'); 10 | const frontendUrl = new URL('https://cool.frontend:8081'); 11 | app.use(originSubstitution(backendUrl, frontendUrl)); 12 | const htmlWithBaseDomain = base => 13 | ` 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 26 | Home 27 | 28 | 29 | `.trim(); 30 | app.get('/', (req, res) => res.send(htmlWithBaseDomain(backendUrl))); 31 | await expect(request(app).get('/')).resolves.toMatchObject({ 32 | text: htmlWithBaseDomain(frontendUrl) 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/WebpackTools/middlewares/__tests__/StaticRootRoute.spec.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const request = require('supertest'); 3 | const express = require('express'); 4 | 5 | const staticRootRoute = require('../StaticRootRoute'); 6 | 7 | test('serves the provided file at root', async () => { 8 | const app = express(); 9 | app.use( 10 | staticRootRoute( 11 | resolve(__dirname, '..', '__fixtures__', 'root-available-file.json') 12 | ) 13 | ); 14 | await expect( 15 | request(app) 16 | .get('/root-available-file.json') 17 | .expect(200) 18 | ).resolves.toMatchObject({ 19 | body: { 20 | goodJsonResponse: true 21 | } 22 | }); 23 | await expect( 24 | request(app) 25 | .get('/some-other-file') 26 | .expect(404) 27 | ).resolves.toMatchObject({ 28 | status: 404 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/MagentoRootComponentsPlugin.spec.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const MemoryFS = require('memory-fs'); 3 | const { promisify: pify } = require('util'); 4 | const webpack = require('webpack'); 5 | const MagentoPageChunksPlugin = require('..'); 6 | 7 | const basic3PageProjectDir = join( 8 | __dirname, 9 | '__fixtures__/basic-project-3-pages' 10 | ); 11 | const basic1PageProjectDir = join( 12 | __dirname, 13 | '__fixtures__/basic-project-1-page' 14 | ); 15 | 16 | const compile = async config => { 17 | const fs = new MemoryFS(); 18 | const compiler = webpack(config); 19 | compiler.outputFileSystem = fs; 20 | 21 | return { 22 | fs, 23 | stats: await pify(compiler.run.bind(compiler))() 24 | }; 25 | }; 26 | 27 | test('Creates a chunk for each root when multiple roots exist', async () => { 28 | const config = { 29 | context: basic3PageProjectDir, 30 | entry: { 31 | main: join(basic3PageProjectDir, 'entry.js') 32 | }, 33 | output: { 34 | path: join(basic3PageProjectDir, 'dist'), 35 | filename: '[name].js', 36 | chunkFilename: '[name].chunk.js' 37 | }, 38 | plugins: [ 39 | new MagentoPageChunksPlugin({ 40 | rootComponentsDirs: [ 41 | join(basic3PageProjectDir, 'RootComponents') 42 | ] 43 | }) 44 | ] 45 | }; 46 | 47 | const { stats } = await compile(config); 48 | expect(stats.compilation.assets['Page1.chunk.js']).toBeTruthy(); 49 | expect(stats.compilation.assets['Page2.chunk.js']).toBeTruthy(); 50 | expect(stats.compilation.assets['Page3.chunk.js']).toBeTruthy(); 51 | }); 52 | 53 | test('Does not prevent chunk name from being configurable', async () => { 54 | const config = { 55 | context: basic3PageProjectDir, 56 | entry: { 57 | main: join(basic3PageProjectDir, 'entry.js') 58 | }, 59 | output: { 60 | path: join(basic3PageProjectDir, 'dist'), 61 | filename: '[name].js', 62 | chunkFilename: '[name].foobar.js' 63 | }, 64 | plugins: [ 65 | new MagentoPageChunksPlugin({ 66 | rootComponentsDirs: [ 67 | join(basic3PageProjectDir, 'RootComponents') 68 | ] 69 | }) 70 | ] 71 | }; 72 | 73 | const { stats } = await compile(config); 74 | expect(stats.compilation.assets['Page1.foobar.js']).toBeTruthy(); 75 | }); 76 | 77 | test('Writes manifest to location specified with "manifestFileName" option', async () => { 78 | const config = { 79 | context: basic3PageProjectDir, 80 | entry: { 81 | main: join(basic3PageProjectDir, 'entry.js') 82 | }, 83 | output: { 84 | path: join(basic3PageProjectDir, 'dist'), 85 | filename: '[name].js', 86 | chunkFilename: '[name].chunk.js' 87 | }, 88 | plugins: [ 89 | new MagentoPageChunksPlugin({ 90 | rootComponentsDirs: [ 91 | join(basic3PageProjectDir, 'RootComponents') 92 | ], 93 | manifestFileName: 'manifest.json' 94 | }) 95 | ] 96 | }; 97 | 98 | const { fs } = await compile(config); 99 | const manifest = fs.readFileSync( 100 | join(basic3PageProjectDir, 'dist/manifest.json'), 101 | 'utf8' 102 | ); 103 | expect(manifest).toBeTruthy(); 104 | }); 105 | 106 | test('Creates chunks for all roots when multiple values are provided in "rootComponentsDirs" config', async () => { 107 | const config = { 108 | context: basic1PageProjectDir, 109 | entry: { 110 | main: join(basic1PageProjectDir, 'entry.js') 111 | }, 112 | output: { 113 | path: join(basic1PageProjectDir, 'dist'), 114 | filename: '[name].js', 115 | chunkFilename: '[name].chunk.js' 116 | }, 117 | plugins: [ 118 | new MagentoPageChunksPlugin({ 119 | rootComponentsDirs: [ 120 | join(basic3PageProjectDir, 'RootComponents'), 121 | join(basic1PageProjectDir, 'RootComponents') 122 | ] 123 | }) 124 | ] 125 | }; 126 | 127 | const { stats } = await compile(config); 128 | expect(stats.compilation.assets['Page1.chunk.js']).toBeTruthy(); 129 | expect(stats.compilation.assets['SomePage.chunk.js']).toBeTruthy(); 130 | }); 131 | 132 | test('Works when there is 1 unnamed entry point in the config', async () => { 133 | const config = { 134 | context: basic3PageProjectDir, 135 | entry: join(basic3PageProjectDir, 'entry.js'), 136 | output: { 137 | path: join(basic3PageProjectDir, 'dist'), 138 | filename: '[name].js', 139 | chunkFilename: '[name].chunk.js' 140 | }, 141 | plugins: [ 142 | new MagentoPageChunksPlugin({ 143 | rootComponentsDirs: [ 144 | join(basic3PageProjectDir, 'RootComponents') 145 | ], 146 | manifestFileName: 'manifest.json' 147 | }) 148 | ] 149 | }; 150 | 151 | const { fs } = await compile(config); 152 | const writtenFiles = fs.readdirSync(config.output.path).sort(); 153 | const expectedFiles = [ 154 | 'Page1.chunk.js', 155 | 'Page2.chunk.js', 156 | 'Page3.chunk.js', 157 | 'main.js', // default entry point name when name isn't provided 158 | 'manifest.json' 159 | ].sort(); 160 | 161 | expect(writtenFiles).toEqual(expectedFiles); 162 | }); 163 | 164 | test('Includes RootComponent description, pageTypes, and chunk filename in the manifest', async () => { 165 | const config = { 166 | context: basic1PageProjectDir, 167 | entry: join(basic1PageProjectDir, 'entry.js'), 168 | output: { 169 | path: join(basic1PageProjectDir, 'dist'), 170 | filename: '[name].js', 171 | chunkFilename: '[name].chunk.js' 172 | }, 173 | plugins: [ 174 | new MagentoPageChunksPlugin({ 175 | rootComponentsDirs: [ 176 | join(basic1PageProjectDir, 'RootComponents') 177 | ], 178 | manifestFileName: 'manifest.json' 179 | }) 180 | ] 181 | }; 182 | 183 | const { fs } = await compile(config); 184 | const manifest = JSON.parse( 185 | fs.readFileSync( 186 | join(basic1PageProjectDir, 'dist/manifest.json'), 187 | 'utf8' 188 | ) 189 | ); 190 | expect(manifest.SomePage.pageTypes).toEqual(['cms_page']); 191 | expect(manifest.SomePage.description).toEqual('CMS Page Root Component'); 192 | expect(manifest.SomePage.chunkName).toBe('SomePage.chunk.js'); 193 | }); 194 | 195 | test('Logs warning when RootComponent file has > 1 @RootComponent comment', async () => { 196 | const projectDir = join(__dirname, '__fixtures__/dupe-root-component'); 197 | const config = { 198 | context: projectDir, 199 | entry: join(projectDir, 'entry.js'), 200 | output: { 201 | path: join(projectDir, 'dist'), 202 | filename: '[name].js' 203 | }, 204 | plugins: [ 205 | new MagentoPageChunksPlugin({ 206 | rootComponentsDirs: [join(projectDir, 'RootComponents')] 207 | }) 208 | ] 209 | }; 210 | 211 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 212 | await compile(config); 213 | expect(console.warn).toHaveBeenCalledWith( 214 | expect.stringMatching(/Found more than 1 RootComponent Directive/) 215 | ); 216 | console.warn.mockRestore(); 217 | }); 218 | 219 | test('Build fails when no @RootComponent directive is found', async () => { 220 | const projectDir = join(__dirname, '__fixtures__/missing-root-directive'); 221 | const config = { 222 | context: projectDir, 223 | entry: join(projectDir, 'entry.js'), 224 | output: { 225 | path: join(projectDir, 'dist'), 226 | filename: '[name].js' 227 | }, 228 | plugins: [ 229 | new MagentoPageChunksPlugin({ 230 | rootComponentsDirs: [join(projectDir, 'RootComponents')] 231 | }) 232 | ] 233 | }; 234 | 235 | const { stats } = await compile(config); 236 | expect(stats.compilation.errors.length).toBe(1); 237 | const [firstError] = stats.compilation.errors; 238 | expect(firstError.message).toMatch( 239 | /Failed to create chunk for the following file, because it is missing a @RootComponent directive/ 240 | ); 241 | }); 242 | 243 | test('Can resolve dependencies of a RootComponent', async () => { 244 | // https://github.com/DrewML/webpack-loadmodule-bug 245 | const projectDir = join(__dirname, '__fixtures__/root-component-dep'); 246 | const config = { 247 | context: projectDir, 248 | entry: join(projectDir, 'entry.js'), 249 | output: { 250 | path: join(projectDir, 'dist'), 251 | filename: '[name].js', 252 | chunkFilename: '[name].chunk.js' 253 | }, 254 | plugins: [ 255 | new MagentoPageChunksPlugin({ 256 | rootComponentsDirs: [join(projectDir, 'RootComponents')] 257 | }) 258 | ] 259 | }; 260 | 261 | const { fs } = await compile(config); 262 | const chunkStr = fs.readFileSync( 263 | join(projectDir, 'dist/Page1.chunk.js'), 264 | 'utf8' 265 | ); 266 | expect(chunkStr).not.toContain('Cannot find module'); 267 | }); 268 | 269 | test('Uglify compiles out dynamic imports injected into entry point', async () => { 270 | const config = { 271 | context: basic1PageProjectDir, 272 | entry: { 273 | main: join(basic1PageProjectDir, 'entry.js') 274 | }, 275 | output: { 276 | path: join(basic1PageProjectDir, 'dist'), 277 | filename: '[name].js', 278 | chunkFilename: '[name].chunk.js' 279 | }, 280 | plugins: [ 281 | new MagentoPageChunksPlugin({ 282 | rootComponentsDirs: [ 283 | join(basic1PageProjectDir, 'RootComponents') 284 | ] 285 | }), 286 | new webpack.optimize.UglifyJsPlugin({ 287 | keep_fnames: true, 288 | mangle: false 289 | }) 290 | ] 291 | }; 292 | 293 | const { fs } = await compile(config); 294 | const entryPointSrc = fs.readFileSync( 295 | join(config.output.path, 'main.js'), 296 | 'utf8' 297 | ); 298 | expect(entryPointSrc).not.toContain('import()'); 299 | expect(entryPointSrc).not.toContain( 300 | 'this_function_will_be_removed_by_uglify' 301 | ); 302 | }); 303 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-1-page/RootComponents/SomePage/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * pageTypes = cms_page 4 | * description = 'CMS Page Root Component' 5 | */ 6 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-1-page/entry.js: -------------------------------------------------------------------------------- 1 | // entry 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-3-pages/RootComponents/Page1/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * pageTypes = product_page 4 | */ 5 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-3-pages/RootComponents/Page2/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * pageTypes = catalog_page 4 | */ 5 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-3-pages/RootComponents/Page3/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * pageTypes = product_page_special 4 | * description = 'Special Product Pages' 5 | */ 6 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/basic-project-3-pages/entry.js: -------------------------------------------------------------------------------- 1 | // entry 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/dupe-root-component/RootComponents/Page1/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * pageTypes = product_page 4 | * description = "test test" 5 | */ 6 | 7 | /** 8 | * @RootComponent 9 | * pageTypes = catalog_page 10 | * description = "this is a duplicate" 11 | */ 12 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/dupe-root-component/entry.js: -------------------------------------------------------------------------------- 1 | // entry 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/missing-root-directive/RootComponents/Page1/index.js: -------------------------------------------------------------------------------- 1 | // should have a root component directive, but does not 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/missing-root-directive/entry.js: -------------------------------------------------------------------------------- 1 | // entry 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/root-component-dep/RootComponents/Page1/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @RootComponent 3 | * description = "This root component's dependencies should resolve" 4 | */ 5 | 6 | import dep from '../../dep'; 7 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/root-component-dep/dep.js: -------------------------------------------------------------------------------- 1 | export default 'dep'; 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/__tests__/__fixtures__/root-component-dep/entry.js: -------------------------------------------------------------------------------- 1 | // entry 2 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/index.js: -------------------------------------------------------------------------------- 1 | const { isAbsolute, join } = require('path'); 2 | const { RawSource } = require('webpack-sources'); 3 | const { 4 | rootComponentMap, 5 | seenRootComponents 6 | } = require('./roots-chunk-loader'); 7 | 8 | const loaderPath = join(__dirname, 'roots-chunk-loader.js'); 9 | 10 | /** 11 | * @description webpack plugin that creates chunks for each 12 | * individual RootComponent in a store, and generates a manifest 13 | * with data for consumption by the backend. 14 | */ 15 | class MagentoRootComponentsPlugin { 16 | /** 17 | * @param {object} opts 18 | * @param {string[]} opts.rootComponentsDirs All directories to be searched for RootComponents 19 | * @param {string} opts.manifestFileName Name of the manifest file to be emitted from the build 20 | */ 21 | constructor(opts = {}) { 22 | const { rootComponentsDirs, manifestFileName } = opts; 23 | this.rootComponentsDirs = rootComponentsDirs || [ 24 | './src/RootComponents' 25 | ]; 26 | this.manifestFileName = manifestFileName || 'roots-manifest.json'; 27 | } 28 | 29 | apply(compiler) { 30 | const { context } = compiler.options; 31 | const { rootComponentsDirs } = this; 32 | 33 | // Create a list of absolute paths for root components. When a 34 | // relative path is found, resolve it from the root context of 35 | // the webpack build 36 | const rootComponentsDirsAbs = rootComponentsDirs.map( 37 | dir => (isAbsolute(dir) ? dir : join(context, dir)) 38 | ); 39 | 40 | const moduleByPath = new Map(); 41 | compiler.plugin('compilation', compilation => { 42 | compilation.plugin('normal-module-loader', (loaderContext, mod) => { 43 | if (seenRootComponents.has(mod.resource)) { 44 | // The module ("mod") has not been assigned an ID yet, 45 | // so we need to keep a reference to it which will allow 46 | // us to grab the ID during the emit phase 47 | moduleByPath.set(mod.resource, mod); 48 | } 49 | // To create a unique chunk for each RootComponent, we want to inject 50 | // a dynamic import() for each RootComponent, within each entry point. 51 | const isAnEntry = compilation.entries.some(entryMod => { 52 | // Check if the module being constructed matches a defined entry point 53 | if (mod === entryMod) return true; 54 | if (!entryMod.identifier().startsWith('multi')) { 55 | return false; 56 | } 57 | 58 | // If a multi-module entry is used (webpack-dev-server creates one), we 59 | // need to try and match against each dependency in the multi module 60 | return entryMod.dependencies.some( 61 | singleDep => singleDep.module === mod 62 | ); 63 | }); 64 | if (!isAnEntry) return; 65 | 66 | // If this module is an entry module, inject a loader in the pipeline 67 | // that will force creation of all our RootComponent chunks 68 | mod.loaders.push({ 69 | loader: loaderPath, 70 | options: { 71 | rootsDirs: rootComponentsDirsAbs 72 | } 73 | }); 74 | }); 75 | }); 76 | 77 | compiler.plugin('emit', (compilation, cb) => { 78 | // Prepare the manifest that the Magento backend can use 79 | // to pick root components for a page. 80 | const namedChunks = Array.from( 81 | Object.values(compilation.namedChunks) 82 | ); 83 | const manifest = namedChunks.reduce((acc, chunk) => { 84 | const { rootDirective, rootComponentPath } = 85 | rootComponentMap.get(chunk.name) || {}; 86 | if (!rootDirective) return acc; 87 | 88 | // Index 0 is always the chunk, but it's an Array because 89 | // there could be a source map (which we don't care about) 90 | const [rootComponentFilename] = chunk.files; 91 | acc[chunk.name] = Object.assign( 92 | { 93 | chunkName: rootComponentFilename, 94 | rootChunkID: chunk.id, 95 | rootModuleID: moduleByPath.get(rootComponentPath).id 96 | }, 97 | rootDirective 98 | ); 99 | return acc; 100 | }, {}); 101 | 102 | compilation.assets[this.manifestFileName] = new RawSource( 103 | JSON.stringify(manifest, null, 4) 104 | ); 105 | cb(); 106 | }); 107 | } 108 | } 109 | 110 | module.exports = MagentoRootComponentsPlugin; 111 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/MagentoRootComponentsPlugin/roots-chunk-loader.js: -------------------------------------------------------------------------------- 1 | const { EOL } = require('os'); 2 | const { join, sep, dirname } = require('path'); 3 | const { promisify: pify } = require('util'); 4 | const directiveParser = require('@magento/directive-parser'); 5 | 6 | /** 7 | * By design, this loader ignores the input content. The loader's sole purpose 8 | * is to dynamically generate import() expressions that tell webpack to create 9 | * chunks for each Root Component in a Magento theme 10 | */ 11 | module.exports = async function rootComponentsChunkLoader(src) { 12 | // Using `this.async()` because webpack's loader-runner lib has a super broken implementation 13 | // of promise support 14 | const cb = this.async(); 15 | const readFile = pify(this.fs.readFile.bind(this.fs)); 16 | 17 | try { 18 | const rootsDirs = this.query.rootsDirs; 19 | 20 | const readdir = pify(this.fs.readdir.bind(this.fs)); 21 | const dirEntries = flatten( 22 | await Promise.all( 23 | rootsDirs.map(async dir => { 24 | const dirs = await readdir(dir); 25 | return dirs.map(d => join(dir, d)); 26 | }) 27 | ) 28 | ); 29 | 30 | const stat = pify(this.fs.stat.bind(this.fs)); 31 | const dirs = await asyncFilter(dirEntries, async dir => { 32 | return (await stat(dir)).isDirectory(); 33 | }); 34 | 35 | const sources = await Promise.all( 36 | dirs.map(async dir => { 37 | const rootComponentPath = join(dir, 'index.js'); // index.js should probably be configurable 38 | // `this.loadModule` would typically be used here, but we can't use it due to a bug 39 | // https://github.com/DrewML/webpack-loadmodule-bug. Instead, we do a read from webpack's MemoryFS. 40 | // The `toString` is necessary because webpack's implementation of the `fs` API doesn't respect the 41 | // encoding param, so the returned value is a Buffer. We don't actually _need_ `this.loadModule`, 42 | // since we're not running the source through a full ECMAScript parser (so we don't need the file post-loaders) 43 | const src = (await readFile(rootComponentPath)).toString(); 44 | 45 | return { 46 | rootComponentPath, 47 | src 48 | }; 49 | }) 50 | ); 51 | 52 | sources.forEach(({ rootComponentPath, src }) => { 53 | const { directives = [], errors } = directiveParser(src); 54 | if (errors.length) { 55 | console.warn( 56 | `Found PWA Studio Directive warning in ${rootComponentPath}` 57 | ); 58 | } 59 | const rootComponentDirectives = directives.filter( 60 | d => d.type === 'RootComponent' 61 | ); 62 | 63 | if (rootComponentDirectives.length > 1) { 64 | console.warn( 65 | `Found more than 1 RootComponent Directive in ${rootComponentPath}. Only the first will be used` 66 | ); 67 | } 68 | 69 | // Because this error is reported from the loader for an entry point, webpack CLI shows 70 | // the error happening within an entry point for the project, rather than the file 71 | // that is missing a directive. TODO: Find a way to report the error for the correct module. 72 | // Likely involves passing the error back to `MagentoRootComponentsPlugin` 73 | if (!rootComponentDirectives.length) { 74 | throw new Error( 75 | `Failed to create chunk for the following file, because it is missing a @RootComponent directive: ${rootComponentPath}` 76 | ); 77 | } 78 | 79 | rootComponentMap.set( 80 | chunkNameFromRootComponentDir(dirname(rootComponentPath)), 81 | { rootDirective: rootComponentDirectives[0], rootComponentPath } 82 | ); 83 | // We already have the rootComponentPath in the rootComponenMap, 84 | // but we need quick "has" validation later, and this won't force us 85 | // to iterate a Map each time looking for a match 86 | seenRootComponents.add(rootComponentPath); 87 | }); 88 | 89 | const dynamicImportCalls = generateDynamicImportCode(dirs); 90 | const finalSrc = `${src};${EOL}${EOL}${dynamicImportCalls}`; 91 | // The entry point source in the graph will now contain the code authored 92 | // in the entry point's file + a dynamic import() for each RootComponent 93 | cb(null, finalSrc); 94 | } catch (err) { 95 | cb(err); 96 | } 97 | }; 98 | 99 | const rootComponentMap = new Map(); 100 | const seenRootComponents = new Set(); 101 | Object.assign(module.exports, { rootComponentMap, seenRootComponents }); 102 | 103 | /** 104 | * @description webpack does not provide a programmatic API to create chunks for 105 | * n files. To get around this, we inject an (unused) function declaration in the entry point, 106 | * that wraps n number of import() calls. This will force webpack to create the chunks we need 107 | * for each RootComponent, but will allow UglifyJS to remove the wrapper function 108 | * (and the dynamic import calls) from the final bundle. 109 | * @param {string[]} dirs 110 | * @returns {string} 111 | */ 112 | function generateDynamicImportCode(dirs) { 113 | // TODO: Dig deeper for an API to programatically create chunks, because 114 | // generating strings of JS is far from ideal 115 | const dynamicImportsStr = dirs 116 | .map(dir => { 117 | const chunkName = chunkNameFromRootComponentDir(dir); 118 | // Right now, Root Components are assumed to always be the `index.js` inside of a 119 | // top-level dir in a specified root dir. We can change this to something else or 120 | // make it configurable later 121 | const fullPath = join(dir, 'index.js'); 122 | return `import(/* webpackChunkName: "${chunkName}" */ '${fullPath}')`; 123 | }) 124 | .join('\n\n'); 125 | 126 | // Note: the __PURE__ comment ensures UglifyJS won't include 127 | // the unnecessary function in the output 128 | return ` 129 | /** Automatically injected by the webpack Magento Plugin **/ 130 | /*#__PURE__*/function this_function_will_be_removed_by_uglify() { 131 | ${dynamicImportsStr} 132 | } 133 | `; 134 | } 135 | 136 | /** 137 | * @param {string} dir 138 | */ 139 | function chunkNameFromRootComponentDir(dir) { 140 | return dir.split(sep).slice(-1)[0]; 141 | } 142 | 143 | /** 144 | * @template T 145 | * @param {T[]} array 146 | * @param {(item: T) => boolean} predicate 147 | * @returns {T[]} 148 | */ 149 | async function asyncFilter(array, predicate) { 150 | const results = await Promise.all( 151 | array.map((...args) => promiseBoolReflect(predicate(...args))) 152 | ); 153 | 154 | return array.filter((_, i) => results[i]); 155 | } 156 | 157 | /** 158 | * @param {Promise} promise 159 | * @returns {Promise} 160 | */ 161 | async function promiseBoolReflect(promise) { 162 | try { 163 | return !!(await promise); 164 | } catch (err) { 165 | return false; 166 | } 167 | } 168 | 169 | function flatten(arr) { 170 | return Array.prototype.concat(...arr); 171 | } 172 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/ServiceWorkerPlugin.js: -------------------------------------------------------------------------------- 1 | // TODO: (p1) write test file and test 2 | const WorkboxPlugin = require('workbox-webpack-plugin'); 3 | const WriteFileWebpackPlugin = require('write-file-webpack-plugin'); 4 | const optionsValidator = require('../../util/options-validator'); 5 | 6 | class ServiceWorkerPlugin { 7 | static validateOptions = optionsValidator('ServiceWorkerPlugin', { 8 | 'env.phase': 'string', 9 | serviceWorkerFileName: 'string', 10 | 'paths.assets': 'string' 11 | }); 12 | constructor(config) { 13 | ServiceWorkerPlugin.validateOptions('ServiceWorkerPlugin', config); 14 | this.config = config; 15 | } 16 | applyWorkbox(compiler) { 17 | const config = { 18 | // `globDirectory` and `globPatterns` must match at least 1 file 19 | // otherwise workbox throws an error 20 | globDirectory: this.config.paths.assets, 21 | // TODO: (feature) autogenerate glob patterns from asset manifest 22 | globPatterns: ['**/*.{gif,jpg,png,svg}'], 23 | 24 | // activate the worker as soon as it reaches the waiting phase 25 | skipWaiting: true, 26 | 27 | // the max scope of a worker is its location 28 | swDest: this.config.serviceWorkerFileName 29 | }; 30 | 31 | if (this.config.runtimeCacheAssetPath) { 32 | config.runtimeCaching = [ 33 | { 34 | urlPattern: new RegExp(this.config.runtimeCacheAssetPath), 35 | handler: 'staleWhileRevalidate' 36 | } 37 | ]; 38 | } 39 | new WorkboxPlugin.GenerateSW(config).apply(compiler); 40 | } 41 | apply(compiler) { 42 | if (this.config.env.phase === 'development') { 43 | // add a WriteFilePlugin to write out the service worker to the filesystem so it can be served by M2, even though it's under dev 44 | if (this.config.enableServiceWorkerDebugging) { 45 | new WriteFileWebpackPlugin({ 46 | test: new RegExp(this.config.serviceWorkerFileName + '$'), 47 | log: true 48 | }).apply(compiler); 49 | this.applyWorkbox(compiler); 50 | } else { 51 | // TODO: (feature) emit a structured { code, severity, resolution } object 52 | // on Environment that might throw and might not 53 | console.warn( 54 | `Emitting no ServiceWorker in development phase. To enable development phase for ServiceWorkers, pass \`enableServiceWorkerDebugging: true\` to the ServiceWorkerPlugin configuration.` 55 | ); 56 | } 57 | } else { 58 | this.applyWorkbox(compiler); 59 | } 60 | } 61 | } 62 | module.exports = ServiceWorkerPlugin; 63 | -------------------------------------------------------------------------------- /src/WebpackTools/plugins/__tests__/ServiceWorkerPlugin.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('workbox-webpack-plugin'); 2 | jest.mock('write-file-webpack-plugin'); 3 | 4 | const WorkboxPlugin = require('workbox-webpack-plugin'); 5 | const WriteFileWebpackPlugin = require('write-file-webpack-plugin'); 6 | 7 | const ServiceWorkerPlugin = require('../ServiceWorkerPlugin'); 8 | 9 | test('throws if options are missing', () => { 10 | expect(() => new ServiceWorkerPlugin({})).toThrow( 11 | 'env.phase must be of type string' 12 | ); 13 | expect( 14 | () => new ServiceWorkerPlugin({ env: { phase: 'development' } }) 15 | ).toThrow('serviceWorkerFileName must be of type string'); 16 | expect( 17 | () => 18 | new ServiceWorkerPlugin({ 19 | env: { phase: 'development' }, 20 | serviceWorkerFileName: 'file.name' 21 | }) 22 | ).toThrow('paths.assets must be of type string'); 23 | }); 24 | 25 | test('returns a valid Webpack plugin', () => { 26 | expect( 27 | new ServiceWorkerPlugin({ 28 | env: { 29 | phase: 'development' 30 | }, 31 | serviceWorkerFileName: 'sw.js', 32 | runtimeCacheAssetPath: 'https://location/of/assets', 33 | paths: { 34 | assets: 'path/to/assets' 35 | } 36 | }) 37 | ).toHaveProperty('apply', expect.any(Function)); 38 | }); 39 | 40 | test('.apply calls WorkboxPlugin.GenerateSW in prod', () => { 41 | const plugin = new ServiceWorkerPlugin({ 42 | env: { 43 | phase: 'production' 44 | }, 45 | serviceWorkerFileName: 'sw.js', 46 | runtimeCacheAssetPath: 'https://location/of/assets', 47 | paths: { 48 | assets: 'path/to/assets' 49 | } 50 | }); 51 | const workboxApply = jest.fn(); 52 | const fakeCompiler = {}; 53 | WorkboxPlugin.GenerateSW.mockImplementationOnce(() => ({ 54 | apply: workboxApply 55 | })); 56 | 57 | plugin.apply(fakeCompiler); 58 | 59 | expect(WriteFileWebpackPlugin).not.toHaveBeenCalled(); 60 | expect(WorkboxPlugin.GenerateSW).toHaveBeenCalledWith( 61 | expect.objectContaining({ 62 | globDirectory: 'path/to/assets', 63 | globPatterns: expect.arrayContaining([expect.any(String)]), 64 | swDest: 'sw.js' 65 | }) 66 | ); 67 | expect(workboxApply).toHaveBeenCalledWith(fakeCompiler); 68 | }); 69 | 70 | test('.apply calls nothing but warns in console in dev', () => { 71 | const plugin = new ServiceWorkerPlugin({ 72 | env: { 73 | phase: 'development' 74 | }, 75 | serviceWorkerFileName: 'sw.js', 76 | runtimeCacheAssetPath: 'https://location/of/assets', 77 | paths: { 78 | assets: 'path/to/assets' 79 | } 80 | }); 81 | jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); 82 | 83 | plugin.apply({}); 84 | 85 | expect(WriteFileWebpackPlugin).not.toHaveBeenCalled(); 86 | expect(WorkboxPlugin.GenerateSW).not.toHaveBeenCalled(); 87 | 88 | expect(console.warn).toHaveBeenCalledWith( 89 | expect.stringContaining( 90 | `Emitting no ServiceWorker in development phase.` 91 | ) 92 | ); 93 | 94 | console.warn.mockRestore(); 95 | }); 96 | 97 | test('.apply generates and writes out a serviceworker when enableServiceWorkerDebugging is set', () => { 98 | const plugin = new ServiceWorkerPlugin({ 99 | env: { 100 | phase: 'development' 101 | }, 102 | enableServiceWorkerDebugging: true, 103 | serviceWorkerFileName: 'sw.js', 104 | runtimeCacheAssetPath: 'https://location/of/assets', 105 | paths: { 106 | assets: 'path/to/assets' 107 | } 108 | }); 109 | 110 | const fakeCompiler = {}; 111 | const workboxApply = jest.fn(); 112 | const writeFileApply = jest.fn(); 113 | WorkboxPlugin.GenerateSW.mockImplementationOnce(() => ({ 114 | apply: workboxApply 115 | })); 116 | WriteFileWebpackPlugin.mockImplementationOnce(() => ({ 117 | apply: writeFileApply 118 | })); 119 | 120 | plugin.apply(fakeCompiler); 121 | 122 | expect(WriteFileWebpackPlugin).toHaveBeenCalledWith( 123 | expect.objectContaining({ 124 | test: expect.objectContaining({ 125 | source: 'sw.js$' 126 | }) 127 | }) 128 | ); 129 | 130 | expect(writeFileApply).toHaveBeenCalledWith(fakeCompiler); 131 | 132 | expect(WorkboxPlugin.GenerateSW).toHaveBeenCalledWith( 133 | expect.objectContaining({ 134 | globDirectory: 'path/to/assets', 135 | globPatterns: expect.arrayContaining([expect.any(String)]), 136 | swDest: 'sw.js' 137 | }) 138 | ); 139 | }); 140 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/extensions/extension1/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magento-extension1" 3 | } 4 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/extensions/extension2/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magento-extension2" 3 | } 4 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/extensions/random-file.txt: -------------------------------------------------------------------------------- 1 | placeholder 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const magentoLayoutLoader = require('./magento-layout-loader'); 2 | 3 | module.exports = { 4 | magentoLayoutLoader, 5 | WebpackTools: require('./WebpackTools') 6 | }; 7 | -------------------------------------------------------------------------------- /src/magento-layout-loader/__tests__/__fixtures__/only-entry/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Should warn about data-mid on Composite Component 4 | const el = ; 5 | -------------------------------------------------------------------------------- /src/magento-layout-loader/__tests__/__snapshots__/babel-plugin-magento-layout.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`"insertAfter" injects an extension after its target ContainerChild 1`] = ` 4 | "import _Extension from '/Users/person/components/SomeComponent.js'; 5 | import React from 'react'; 6 | import { ContainerChild } from '@magento/peregrine'; 7 | React.createElement( 8 | 'div', 9 | { 'data-mid': 'product.page.pricing' }, 10 | React.createElement(ContainerChild, { 11 | id: 'product.page.pricing.add.button', 12 | render: () => {} 13 | }), 14 | React.createElement(_Extension, null) 15 | );" 16 | `; 17 | 18 | exports[`"insertBefore" injects an extension before its target ContainerChild 1`] = ` 19 | "import _Extension from '/Users/person/components/SomeComponent.js'; 20 | import React from 'react'; 21 | import { ContainerChild } from '@magento/peregrine'; 22 | React.createElement( 23 | 'div', 24 | { 'data-mid': 'product.page.pricing' }, 25 | React.createElement(_Extension, null), 26 | React.createElement(ContainerChild, { 27 | id: 'product.page.pricing.add.button', 28 | render: () => {} 29 | }) 30 | );" 31 | `; 32 | 33 | exports[`"removeChild" operation removes specified child, but not other children 1`] = ` 34 | "import React from 'react'; 35 | import { ContainerChild } from '@magento/peregrine'; 36 | React.createElement( 37 | 'div', 38 | { 'data-mid': 'product.page.pricing' }, 39 | React.createElement(ContainerChild, { 40 | id: 'product.page.pricing.some.other.button', 41 | render: () => React.createElement( 42 | 'div', 43 | null, 44 | 'I should render' 45 | ) 46 | }) 47 | );" 48 | `; 49 | 50 | exports[`"removeContainer" operation removes container 1`] = ` 51 | "import React from 'react'; 52 | React.createElement( 53 | 'div', 54 | null, 55 | 'Should remove container below' 56 | );" 57 | `; 58 | 59 | exports[`A removeChild operation works when ContainerChild is aliased in import 1`] = ` 60 | "import React from 'react'; 61 | import { ContainerChild as CC } from '@magento/peregrine'; 62 | React.createElement( 63 | 'div', 64 | { 'data-mid': 'product.page.pricing' } 65 | );" 66 | `; 67 | 68 | exports[`Operations work when createElement is used instead of React.createElement 1`] = ` 69 | "import { createElement } from 'react'; 70 | createElement( 71 | 'div', 72 | null, 73 | 'Should remove container below' 74 | );" 75 | `; 76 | -------------------------------------------------------------------------------- /src/magento-layout-loader/__tests__/babel-plugin-magento-layout.spec.js: -------------------------------------------------------------------------------- 1 | const dedent = require('dedent'); 2 | const plugin = require('../babel-plugin-magento-layout'); 3 | const babel = require('babel-core'); 4 | const jsxTransform = require('babel-plugin-transform-react-jsx'); 5 | 6 | const transform = (opts, input, pragma) => { 7 | const { code } = babel.transform(input, { 8 | plugins: [[jsxTransform, { pragma }], [plugin, opts]] 9 | }); 10 | return code; 11 | }; 12 | 13 | test('Warns if "data-mid" is found on a Composite Component', () => { 14 | const onWarning = jest.fn(); 15 | const opts = { 16 | config: { 17 | 'product.page': [ 18 | { 19 | operation: 'removeContainer', 20 | targetContainer: 'product.page' 21 | } 22 | ] 23 | }, 24 | onWarning 25 | }; 26 | transform( 27 | opts, 28 | dedent` 29 | import React from 'react'; 30 | 31 | ` 32 | ); 33 | expect(onWarning).toHaveBeenCalledWith( 34 | expect.stringContaining('"data-mid" found on a Composite Component') 35 | ); 36 | }); 37 | 38 | test('onWarning callback invoked when data-mid is a dynamic value', () => { 39 | const onWarning = jest.fn(); 40 | const opts = { 41 | config: { 42 | 'product.page.pricing': [] 43 | }, 44 | onWarning 45 | }; 46 | transform( 47 | opts, 48 | dedent` 49 | import React from 'react'; 50 |
51 | ` 52 | ); 53 | expect(onWarning).toHaveBeenCalledWith( 54 | expect.stringContaining('Expected "data-mid" to be a literal string') 55 | ); 56 | }); 57 | 58 | test('Operations work when createElement is used instead of React.createElement', () => { 59 | const opts = { 60 | config: { 61 | 'product.page': [ 62 | { 63 | operation: 'removeContainer', 64 | targetContainer: 'product.page' 65 | } 66 | ] 67 | } 68 | }; 69 | const result = transform( 70 | opts, 71 | dedent` 72 | import { createElement } from 'react'; 73 |
74 | Should remove container below 75 |
76 |
77 | `, 78 | 'createElement' 79 | ); 80 | expect(result).toMatchSnapshot(); 81 | }); 82 | 83 | test('"removeContainer" operation removes container', () => { 84 | const opts = { 85 | config: { 86 | 'product.page': [ 87 | { 88 | operation: 'removeContainer', 89 | targetContainer: 'product.page' 90 | } 91 | ] 92 | } 93 | }; 94 | const result = transform( 95 | opts, 96 | dedent` 97 | import React from 'react'; 98 |
99 | Should remove container below 100 |
101 |
102 | ` 103 | ); 104 | expect(result).toMatchSnapshot(); 105 | }); 106 | 107 | test('"removeChild" operation removes specified child, but not other children', () => { 108 | const opts = { 109 | config: { 110 | 'product.page.pricing': [ 111 | { 112 | operation: 'removeChild', 113 | targetContainer: 'product.page.pricing', 114 | targetChild: 'product.page.pricing.add.button' 115 | } 116 | ] 117 | } 118 | }; 119 | const result = transform( 120 | opts, 121 | dedent` 122 | import React from 'react'; 123 | import { ContainerChild } from '@magento/peregrine'; 124 |
125 | 128 |
I should not render
129 | } 130 | /> 131 |
I should render
} 134 | /> 135 |
136 | ` 137 | ); 138 | expect(result).toMatchSnapshot(); 139 | }); 140 | 141 | test('A removeChild operation works when ContainerChild is aliased in import', () => { 142 | const opts = { 143 | config: { 144 | 'product.page.pricing': [ 145 | { 146 | operation: 'removeChild', 147 | targetContainer: 'product.page.pricing', 148 | targetChild: 'product.page.pricing.add.button' 149 | } 150 | ] 151 | } 152 | }; 153 | const result = transform( 154 | opts, 155 | dedent` 156 | import React from 'react'; 157 | import { ContainerChild as CC } from '@magento/peregrine'; 158 |
159 | 162 |
I should not render
163 | } 164 | /> 165 |
166 | ` 167 | ); 168 | expect(result).toMatchSnapshot(); 169 | }); 170 | 171 | test('"insertBefore" injects an extension before its target ContainerChild', () => { 172 | const opts = { 173 | config: { 174 | 'product.page.pricing': [ 175 | { 176 | operation: 'insertBefore', 177 | targetContainer: 'product.page.pricing', 178 | targetChild: 'product.page.pricing.add.button', 179 | componentPath: '/Users/person/components/SomeComponent.js' 180 | } 181 | ] 182 | } 183 | }; 184 | const result = transform( 185 | opts, 186 | dedent` 187 | import React from 'react'; 188 | import { ContainerChild } from '@magento/peregrine'; 189 |
190 | {}} 193 | /> 194 |
195 | ` 196 | ); 197 | expect(result).toMatchSnapshot(); 198 | }); 199 | 200 | test('"insertAfter" injects an extension after its target ContainerChild', () => { 201 | const opts = { 202 | config: { 203 | 'product.page.pricing': [ 204 | { 205 | operation: 'insertAfter', 206 | targetContainer: 'product.page.pricing', 207 | targetChild: 'product.page.pricing.add.button', 208 | componentPath: '/Users/person/components/SomeComponent.js' 209 | } 210 | ] 211 | } 212 | }; 213 | const result = transform( 214 | opts, 215 | dedent` 216 | import React from 'react'; 217 | import { ContainerChild } from '@magento/peregrine'; 218 |
219 | {}} 222 | /> 223 |
224 | ` 225 | ); 226 | expect(result).toMatchSnapshot(); 227 | }); 228 | -------------------------------------------------------------------------------- /src/magento-layout-loader/__tests__/magento-layout-loader.spec.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const MemoryFS = require('memory-fs'); 3 | const { promisify: pify } = require('util'); 4 | const webpack = require('webpack'); 5 | const magentoLayoutLoaderPath = require.resolve('..'); 6 | 7 | const compile = async config => { 8 | const fs = new MemoryFS(); 9 | const compiler = webpack(config); 10 | compiler.outputFileSystem = fs; 11 | 12 | return { 13 | fs, 14 | stats: await pify(compiler.run.bind(compiler))() 15 | }; 16 | }; 17 | 18 | const babelLoaderConfig = { 19 | loader: 'babel-loader', 20 | options: { 21 | plugins: ['transform-react-jsx'], 22 | babelrc: false 23 | } 24 | }; 25 | 26 | test('Warnings from babel plugin are mapped to the correct webpack module', async () => { 27 | const fixtureRoot = join(__dirname, '__fixtures__/only-entry'); 28 | const config = { 29 | entry: join(fixtureRoot, 'index.js'), 30 | output: { 31 | path: join(fixtureRoot, 'dist') 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.js/, 37 | use: [ 38 | { 39 | loader: magentoLayoutLoaderPath, 40 | options: { config: {} } 41 | }, 42 | babelLoaderConfig 43 | ] 44 | } 45 | ] 46 | } 47 | }; 48 | 49 | const { compilation } = (await compile(config)).stats; 50 | const [entryModule] = compilation.entries; 51 | const [warning] = entryModule.warnings; 52 | expect(warning.message).toContain( 53 | '"data-mid" found on a Composite Component' 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /src/magento-layout-loader/__tests__/validateConfig.spec.js: -------------------------------------------------------------------------------- 1 | const validateConfig = require('../validateConfig'); 2 | 3 | test('Returns actionable error for missing required field', () => { 4 | const config = { 5 | 'foo.bar': [ 6 | { 7 | operation: 'removeContainer' 8 | } 9 | ] 10 | }; 11 | const { error } = validateConfig(config); 12 | expect(error).toContain('should have required property'); 13 | }); 14 | 15 | test('Returns actionable error for typos/extra props', () => { 16 | const config = { 17 | 'foo.bar': [ 18 | { 19 | operation: 'removeContainer', 20 | targetcontainer: 'foo.bar' 21 | } 22 | ] 23 | }; 24 | const { error } = validateConfig(config); 25 | expect(error).toContain('should NOT have additional properties'); 26 | }); 27 | 28 | test('Returns actionable error for incorrect types', () => { 29 | const config = { 30 | 'foo.bar': [ 31 | { 32 | operation: 'removeContainer', 33 | targetContainer: 1 34 | } 35 | ] 36 | }; 37 | const { error } = validateConfig(config); 38 | // Note: The full error returned here doesn't specify 39 | // which field had the wrong type, which is awful. Should 40 | // iterate on this further 41 | expect(error).toContain('should be string'); 42 | }); 43 | 44 | test('Validates all configs for a single Container', () => { 45 | const config = { 46 | 'foo.bar': [ 47 | { 48 | // correct 49 | operation: 'removeContainer', 50 | targetContainer: 'foo.bar' 51 | }, 52 | { 53 | // wrong 54 | operation: 'removeChild', 55 | targetContainer: 'bizz.bazz' 56 | } 57 | ] 58 | }; 59 | const { error } = validateConfig(config); 60 | expect(error).toContain('should have required property'); 61 | }); 62 | 63 | test('Validates all configs for multiple Containers', () => { 64 | const config = { 65 | 'foo.bar': [ 66 | { 67 | // correct 68 | operation: 'removeContainer', 69 | targetContainer: 'foo.bar' 70 | } 71 | ], 72 | 'bizz.bazz': [ 73 | { 74 | // wrong 75 | operation: 'removeChild', 76 | targetContainer: 'bizz.bazz' 77 | } 78 | ] 79 | }; 80 | const { error } = validateConfig(config); 81 | expect(error).toContain('should have required property'); 82 | }); 83 | -------------------------------------------------------------------------------- /src/magento-layout-loader/babel-plugin-magento-layout.js: -------------------------------------------------------------------------------- 1 | const validateConfig = require('./validateConfig'); 2 | const { addDefault } = require('babel-helper-module-imports'); 3 | 4 | const noop = () => {}; 5 | module.exports = babelPluginMagentoLayout; 6 | 7 | function babelPluginMagentoLayout({ types: t }) { 8 | // Babel 6 only let's you read plugin options inside visitors, 9 | // so we can't warn about an invalid config until the first file is hit. 10 | // TODO: In Babel 7, use config passed in when plugin is first created. For now, 11 | // this hacky flag is kept in a closure to prevent us from doing config validation 12 | // on every file transformed 13 | let validationRan; 14 | 15 | return { 16 | visitor: { 17 | Program: { 18 | // Our plugin could be (and frequently is) running along with other Babel 19 | // plugins, including the JSX transform. Babel's visitor merging behavior 20 | // will make this plugin susceptible to hard-to-debug plugin/preset ordering 21 | // issues. To avoid this entirely, we don't start our work until the depth-first 22 | // traversal of the AST completes. On Program:exit, we can safely start our work 23 | exit(programPath, state) { 24 | // Babel 6 only let's you read plugin options inside visitors, 25 | // so we can't warn about an invalid config until the first file is hit. 26 | // TODO: In Babel 7, use config passed in when plugin is first created 27 | const { 28 | config, 29 | prod = false, 30 | onWarning = noop, 31 | onError = noop 32 | } = state.opts; 33 | 34 | if (!validationRan) { 35 | validationRan = true; 36 | if (typeof prod !== 'boolean') { 37 | onError( 38 | `Expected "prod" to be a boolean, but received "${typeof prod}"` 39 | ); 40 | } 41 | if (typeof config !== 'object') { 42 | onError( 43 | `Expected "config" to be an object, but received "${typeof config}"` 44 | ); 45 | } 46 | 47 | const { passed, error } = validateConfig(config); 48 | if (!passed) { 49 | // Warn about invalid config, but let the compilation 50 | // keep going 51 | onWarning(error); 52 | } 53 | } 54 | 55 | // We need to find any identifiers in scope that could be used to call 56 | // React's `createElement` function. 57 | const reactModuleImports = identifiersInScopeFromModule( 58 | programPath, 59 | /^react$/ 60 | ); 61 | const { 62 | reactIdentifier, 63 | createElementIdentifier 64 | } = reactModuleImports.reduce((acc, importData) => { 65 | if (importData.bindingName === 'default') { 66 | acc.reactIdentifier = importData.local; 67 | } 68 | if (importData.bindingName === 'createElement') { 69 | acc.createElementIdentifier = importData.local; 70 | } 71 | return acc; 72 | }, {}); 73 | 74 | // No element creation in this file, so we bail 75 | if (!(reactIdentifier || createElementIdentifier)) { 76 | return; 77 | } 78 | 79 | // Start the transformation traversal 80 | programPath.traverse({ 81 | CallExpression(path) { 82 | const isCECall = isCreateElementCall( 83 | path, 84 | reactIdentifier, 85 | createElementIdentifier 86 | ); 87 | if (!isCECall) return; 88 | 89 | const [element] = path.get('arguments'); 90 | const dataMIDPropNode = getProp(path, 'data-mid'); 91 | if (!dataMIDPropNode) return; 92 | 93 | const { value: dataMID, type } = dataMIDPropNode; 94 | if (type !== 'StringLiteral') { 95 | onWarning( 96 | 'Expected "data-mid" to be a literal string, ' + 97 | `but instead found a value of type "${type}"` 98 | ); 99 | } 100 | 101 | if (!t.isStringLiteral(element)) { 102 | onWarning( 103 | '"data-mid" found on a Composite Component.' + 104 | 'Only DOM elements(div/span/etc) can be a Layout Container' 105 | ); 106 | } 107 | 108 | const operations = config[dataMID]; 109 | // No operations were registered for this Container 110 | if (!(operations && operations.length)) { 111 | return; 112 | } 113 | 114 | new ContainerOperationsProcessor({ 115 | types: t, 116 | operations, 117 | containerPath: path, 118 | containerMID: dataMID, 119 | program: programPath, 120 | reactIdentifier, 121 | createElementIdentifier, 122 | onWarning, 123 | onError 124 | }).execute(); 125 | } 126 | }); 127 | } 128 | } 129 | } 130 | }; 131 | } 132 | 133 | class ContainerOperationsProcessor { 134 | constructor({ 135 | types, 136 | operations, 137 | containerPath, 138 | containerMID, 139 | program, 140 | reactIdentifier, 141 | createElementIdentifier, 142 | onWarning, 143 | onError 144 | }) { 145 | Object.assign(this, { 146 | types, 147 | operations, 148 | containerPath, 149 | containerMID, 150 | program, 151 | reactIdentifier, 152 | createElementIdentifier, 153 | onWarning, 154 | onError 155 | }); 156 | this.step = 0; 157 | this.cachedContainerChildIdent = null; 158 | } 159 | 160 | get currentOperation() { 161 | return this.operations[this.step]; 162 | } 163 | 164 | get isDone() { 165 | return this.step + 1 > this.operations.length; 166 | } 167 | 168 | execute() { 169 | while (!this.isDone) { 170 | this[this.currentOperation.operation](); 171 | ++this.step; 172 | } 173 | } 174 | 175 | /** 176 | * Determine the local identifier for Peregrine's `ContainerChild` component. 177 | * This method makes the assumption that the module specifier for a Peregrine 178 | * import will always be `@magento/peregrine`. Can make this configurable in the 179 | * future if necessary. 180 | * @returns {string|null} 181 | */ 182 | getContainerChildIdentifier() { 183 | const { cachedContainerChildIdent } = this; 184 | if (cachedContainerChildIdent) return cachedContainerChildIdent; 185 | 186 | const { program } = this; 187 | const containerChildIdent = identifiersInScopeFromModule( 188 | program, 189 | /^@magento\/peregrine$/ 190 | ).find(({ bindingName }) => bindingName === 'ContainerChild'); 191 | 192 | if (containerChildIdent) { 193 | this.cachedContainerChildIdent = containerChildIdent.local; 194 | } 195 | 196 | return this.cachedContainerChildIdent; 197 | } 198 | 199 | /** 200 | * Given the identifier for a , will return a Babel path 201 | * for the matching child in the currently-targeted Container 202 | * @param {string} name 203 | * @returns {Path|null} 204 | */ 205 | findContainerChildByName(targetChildID) { 206 | const { 207 | containerPath, 208 | reactIdentifier, 209 | createElementIdentifier 210 | } = this; 211 | const containerChildIdent = this.getContainerChildIdentifier(); 212 | 213 | const [, , ...children] = this.containerPath.get('arguments'); 214 | const matchingChild = children.find(child => { 215 | // TODO: warn when child is not a ContainerChild 216 | const isElement = 217 | child.isCallExpression() && 218 | isCreateElementCall( 219 | containerPath, 220 | reactIdentifier, 221 | createElementIdentifier 222 | ); 223 | if (!isElement) return; 224 | 225 | // Verify the child is a Peregrine ContainerChild 226 | const [elementIdentifier] = child.node.arguments; 227 | if (elementIdentifier.name !== containerChildIdent) return; 228 | 229 | const idPropNode = getProp(child, 'id'); 230 | return idPropNode && idPropNode.value === targetChildID; 231 | }); 232 | return matchingChild || null; 233 | } 234 | 235 | /** 236 | * Given an element name, and optional props and children, 237 | * returns a Node representing a call to React's createElement 238 | * @param {string} element Identifier for Composite Component or DOM Element 239 | * @param {object=} props 240 | * @returns {Node} 241 | */ 242 | buildCreateElementCall(element, props) { 243 | const { types: t, reactIdentifier, createElementIdentifier } = this; 244 | const callee = createElementIdentifier 245 | ? t.identifier(createElementIdentifier) 246 | : t.memberExpression( 247 | t.identifier(reactIdentifier), 248 | t.identifier('createElement') 249 | ); 250 | const elementNode = !t.react.isCompatTag(element) 251 | ? t.identifier(element) // Composite Component 252 | : t.stringLiteral(element); // DOM Element 253 | return t.callExpression(callee, [ 254 | elementNode, 255 | props || t.nullLiteral() 256 | ]); 257 | } 258 | 259 | /** 260 | * Within a CallExpression, inserts a new expression AST node 261 | * before or after the specified argument 262 | * @param {'before'|'after'} position 263 | * @param {Path} Babel path for a CallExpression 264 | * @param {Node} targetArg AST Node in the arguments array to insert adjacent to 265 | * @param {Node} exprNode An AST node representing any expression 266 | */ 267 | insertAdjacentArgumentsNode(position, callPath, targetArg, exprNode) { 268 | const { arguments: args } = callPath.node; 269 | const targetArgIndex = args.indexOf(targetArg); 270 | const targetIndex = 271 | position === 'before' ? targetArgIndex : targetArgIndex + 1; 272 | args.splice(targetIndex, 0, exprNode); 273 | } 274 | 275 | /** 276 | * Used for insertBefore/insertAfter operations. Given a before 277 | * or after position, locates the target ContainerChild in the 278 | * current Container, and inserts an import declaration for an 279 | * extension, along with the element in the proper position 280 | * @param {'before'|'after'} position 281 | */ 282 | insertElementAdjacentToChild(position) { 283 | const { containerPath, currentOperation, onWarning } = this; 284 | const { targetChild, componentPath } = currentOperation; 285 | const targetChildPath = this.findContainerChildByName(targetChild); 286 | if (!targetChildPath) { 287 | onWarning( 288 | `Attempted to inject a PWA Studio extension, but specified targetChild was not found\n` + 289 | `operation: ${currentOperation.operation}\n` + 290 | `targetContainer: ${currentOperation.targetContainer}\n` + 291 | `targetChild: ${currentOperation.targetChild}` 292 | ); 293 | return; 294 | } 295 | const componentIdent = addDefault(targetChildPath, componentPath, { 296 | nameHint: 'Extension' 297 | }).name; 298 | // TODO: extensionNode needs to be wrapped in a new ContainerChild, 299 | // and an error boundary 300 | const extensionNode = this.buildCreateElementCall(componentIdent); 301 | this.insertAdjacentArgumentsNode( 302 | position, 303 | containerPath, 304 | targetChildPath.node, 305 | extensionNode 306 | ); 307 | } 308 | 309 | removeContainer() { 310 | const { isDone, containerPath, containerMID, onWarning } = this; 311 | containerPath.remove(); 312 | if (!isDone) { 313 | // TODO: Consider listing the operations that were skipped, 314 | // so a developer knows what extensions likely will not work. 315 | // An extension operating on a removed container indicates a conflict 316 | // between 2 modules 317 | onWarning( 318 | `A remove operation was executed on Container ` + 319 | `${containerMID}, but other operations were still ` + 320 | 'pending on it. This most commonly indicates' + 321 | 'a conflict or ordering issue between modules/extensions' 322 | ); 323 | } 324 | // Short-circuit operations, since there is no more work to do 325 | // on a removed Container 326 | this.step = this.operations.length; 327 | } 328 | 329 | /** 330 | * Remove a ContainerChild inside of a Container 331 | */ 332 | removeChild() { 333 | const { currentOperation, onWarning } = this; 334 | const { targetChild } = currentOperation; 335 | const targetChildPath = this.findContainerChildByName(targetChild); 336 | if (!targetChildPath) { 337 | onWarning( 338 | `Attempted to remove a PWA Studio ContainerChild, but could not locate it\n` + 339 | 'operation: removeChild\n' + 340 | `targetContainer: ${currentOperation.targetContainer}\n` + 341 | `targetChild: ${currentOperation.targetChild}\n` 342 | ); 343 | return; 344 | } 345 | targetChildPath.remove(); 346 | } 347 | 348 | /** 349 | * Insert an element before the specified ContainerChild 350 | */ 351 | insertBefore() { 352 | this.insertElementAdjacentToChild('before'); 353 | } 354 | 355 | /** 356 | * Insert an element after the specified ContainerChild 357 | */ 358 | insertAfter() { 359 | this.insertElementAdjacentToChild('after'); 360 | } 361 | } 362 | 363 | /** 364 | * Given a Babel path wrapping a call to createElement, 365 | * will return the value of the specified prop, if present. 366 | * @param {Path} propsPath 367 | * @param {string} prop 368 | * @returns {Node} 369 | */ 370 | function getProp(callExprPath, propName) { 371 | const [, props] = callExprPath.get('arguments'); 372 | // Could be a NullLiteral if the element has no props 373 | if (!props.isObjectExpression()) return; 374 | 375 | for (const prop of props.node.properties) { 376 | // Key can either be an identifier (.name) or a string literal (.value) 377 | const currentPropName = prop.key.value || prop.key.name; 378 | if (currentPropName === propName) return prop.value; 379 | } 380 | } 381 | 382 | /** 383 | * Given a Babel path wrapping a CallExpression, 384 | * returns a boolean indictating whether the path 385 | * is a call to React's createElement function 386 | * @param {Path} callExprPath 387 | * @param {string=} reactIdent String representing the identifier in scope for React 388 | * @param {string=} createElementIdent String representing the identifier in scope for createElement 389 | * @returns {bool} 390 | */ 391 | function isCreateElementCall(callExprPath, reactIdent, createElementIdent) { 392 | const callee = callExprPath.get('callee'); 393 | // React.createElement() 394 | if (callee.isMemberExpression()) { 395 | return callee.matchesPattern(`${reactIdent}.createElement`); 396 | } 397 | 398 | // createElement() 399 | if (callee.isIdentifier()) { 400 | return callee.equals('name', createElementIdent); 401 | } 402 | 403 | return false; 404 | } 405 | 406 | /** 407 | * Given a Babel path wrapping the `Program` node, and 408 | * a regex for matching against module specifiers, returns 409 | * named (and default) imports pulled into scope from that 410 | * module, and their optionally aliased values 411 | * @param {Path} programPath 412 | * @param {RegExp} reModuleSpec 413 | * @returns {Array<{bindingName: string, local: string}>} 414 | */ 415 | function identifiersInScopeFromModule(programPath, reModuleSpec) { 416 | const targetImportDecls = programPath.get('body').filter(path => { 417 | if (!path.isImportDeclaration()) return; 418 | const moduleSpec = path.node.source.value; 419 | return reModuleSpec.test(moduleSpec); 420 | }); 421 | 422 | return targetImportDecls.reduce((acc, importDecl) => { 423 | for (const spec of importDecl.node.specifiers) { 424 | acc.push({ 425 | bindingName: 426 | spec.type === 'ImportDefaultSpecifier' 427 | ? 'default' 428 | : spec.imported.name, 429 | local: spec.local.name 430 | }); 431 | } 432 | 433 | return acc; 434 | }, []); 435 | } 436 | -------------------------------------------------------------------------------- /src/magento-layout-loader/index.js: -------------------------------------------------------------------------------- 1 | const babelLoader = require('babel-loader'); 2 | const babelPlugin = require('./babel-plugin-magento-layout'); 3 | const dynamicImportSyntax = require('babel-plugin-syntax-dynamic-import'); 4 | 5 | /** 6 | * The Magento Layout Loader is a small wrapper around 7 | * babel-loader and a babel plugin. 8 | */ 9 | module.exports = function magentoLayoutLoader(...args) { 10 | // Don't bother supporting string-based queries until we need to 11 | const loaderConfig = this.query || {}; 12 | 13 | const babelPluginConfig = Object.assign({}, loaderConfig, { 14 | onWarning: warning => { 15 | const err = new Error(`magento-layout-loader: ${warning}`); 16 | this._module.warnings.push(err); 17 | }, 18 | onError: error => { 19 | const err = new Error(`magento-layout-loader: ${error}`); 20 | this._module.errors.push(err); 21 | } 22 | }); 23 | 24 | // Options we want to pass through to the babel-loader 25 | // we are wrapping 26 | const query = { 27 | babelrc: false, 28 | plugins: [dynamicImportSyntax, [babelPlugin, babelPluginConfig]] 29 | }; 30 | 31 | // Yuck. babel-loader reads options from the webpack loader's 32 | // `this.query` property (through loader-utils). We need to set this property 33 | // to pass through options, but webpack has a `getter` without a `setter` 34 | // so we can't just mutate `this.query`. Instead of the mutation, 35 | // we create a new object with the proto pointed at the current `this`, 36 | // and just shadow the `query` prop 37 | const shadowThis = Object.create(this, { 38 | query: { value: query } 39 | }); 40 | 41 | return babelLoader.apply(shadowThis, args); 42 | }; 43 | -------------------------------------------------------------------------------- /src/magento-layout-loader/validateConfig.js: -------------------------------------------------------------------------------- 1 | const Ajv = require('ajv'); 2 | 3 | const ajv = new Ajv(); 4 | 5 | /** 6 | * Given an aggregate configuration for all layout operations 7 | * (generated by the Magento store), validates each config conforms 8 | * to babel-plugin-magento-layout's requirements 9 | * 10 | * @param {object} config 11 | * @returns {{ passed: bool, error: string | null }} 12 | */ 13 | module.exports = config => { 14 | for (const rules of Object.values(config)) { 15 | for (const rule of rules) { 16 | const validator = validatorsByOperation[rule.operation]; 17 | validator(rule); 18 | if (validator.errors && validator.errors.length) { 19 | return { 20 | passed: false, 21 | error: formatError(validator.errors[0], rule) 22 | }; 23 | } 24 | } 25 | } 26 | 27 | return { passed: true, error: null }; 28 | }; 29 | 30 | function formatError(error, rule) { 31 | const { message } = error; 32 | return ( 33 | `Encountered a layout configuration that does not match the required schema.\n` + 34 | `Message: ${message}\n` + 35 | `Received: ${JSON.stringify(rule)}` 36 | ); 37 | } 38 | 39 | const validatorsByOperation = { 40 | removeContainer: ajv.compile({ 41 | additionalProperties: false, 42 | required: ['operation', 'targetContainer'], 43 | properties: { 44 | operation: { constant: 'removeContainer' }, 45 | targetContainer: { type: 'string' } 46 | } 47 | }), 48 | 49 | removeChild: ajv.compile({ 50 | additionalProperties: false, 51 | required: ['operation', 'targetContainer', 'targetChild'], 52 | properties: { 53 | operation: { constant: 'removeChild' }, 54 | targetContainer: { type: 'string' }, 55 | targetChild: { type: 'string' } 56 | } 57 | }), 58 | 59 | insertBefore: ajv.compile({ 60 | additionalProperties: false, 61 | required: [ 62 | 'operation', 63 | 'targetContainer', 64 | 'targetChild', 65 | 'componentPath' 66 | ], 67 | properties: { 68 | operation: { constant: 'insertBefore' }, 69 | targetContainer: { type: 'string' }, 70 | targetChild: { type: 'string' }, 71 | componentPath: { type: 'string' } 72 | } 73 | }), 74 | 75 | insertAfter: ajv.compile({ 76 | additionalProperties: false, 77 | required: [ 78 | 'operation', 79 | 'targetContainer', 80 | 'targetChild', 81 | 'componentPath' 82 | ], 83 | properties: { 84 | operation: { constant: 'insertAfter' }, 85 | targetContainer: { type: 'string' }, 86 | targetChild: { type: 'string' }, 87 | componentPath: { type: 'string' } 88 | } 89 | }) 90 | }; 91 | -------------------------------------------------------------------------------- /src/util/__tests__/debug.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('debug'); 2 | const { join } = require('path'); 3 | const debug = require('debug'); 4 | 5 | const myDebug = require('../debug'); 6 | 7 | beforeEach(() => { 8 | // must return a function, that's all 9 | debug.mockImplementation(() => () => {}); 10 | }); 11 | afterEach(() => jest.resetAllMocks()); 12 | 13 | test('here(path) creates logger with pathname driven tag', () => { 14 | myDebug.makeFileLogger(__filename); 15 | expect(debug).toHaveBeenCalledWith( 16 | 'pwa-buildpack:util:__tests__:debug.spec.js' 17 | ); 18 | }); 19 | test('here(path) logger tag does not include index.js', () => { 20 | myDebug.makeFileLogger(join(__dirname, 'subdir', 'index.js')); 21 | expect(debug).toHaveBeenCalledWith('pwa-buildpack:util:__tests__:subdir'); 22 | }); 23 | 24 | test('here(path) logger tag formats an error message', () => { 25 | expect(myDebug.makeFileLogger(__dirname).errorMsg('foo')).toBe( 26 | '[pwa-buildpack:util:__tests__] foo' 27 | ); 28 | }); 29 | 30 | test('here(path) logger.sub produces a sub-logger', () => { 31 | myDebug.makeFileLogger(__dirname).sub('extra'); 32 | expect(debug.mock.calls[1][0]).toBe('pwa-buildpack:util:__tests__:extra'); 33 | }); 34 | -------------------------------------------------------------------------------- /src/util/__tests__/global-config.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('crypto'); 2 | jest.mock('os'); 3 | jest.mock('flat-file-db'); 4 | 5 | const crypto = require('crypto'); 6 | const os = require('os'); 7 | const flatfile = require('flat-file-db'); 8 | 9 | let GlobalConfig; 10 | 11 | const mockHash = { 12 | update: jest.fn(), 13 | digest: jest.fn(() => 'fakeDigest') 14 | }; 15 | 16 | const mockDb = { 17 | on: jest.fn((type, callback) => { 18 | if (type === 'open') setImmediate(callback); 19 | }), 20 | get: jest.fn(), 21 | put: jest.fn((k, v, cb) => setImmediate(cb)), 22 | del: jest.fn((k, cb) => setImmediate(cb)), 23 | clear: jest.fn(setImmediate), 24 | keys: jest.fn() 25 | }; 26 | 27 | beforeAll(() => { 28 | GlobalConfig = require('../global-config'); 29 | flatfile.mockImplementation(() => mockDb); 30 | os.homedir.mockReturnValue('/_HOME_'); 31 | crypto.createHash.mockReturnValue(mockHash); 32 | }); 33 | 34 | beforeEach(() => { 35 | delete GlobalConfig._dbPromise; 36 | }); 37 | 38 | test('static getDbFilePath() creates a hidden file path global to user', () => { 39 | expect(GlobalConfig.getDbFilePath()).toBe( 40 | '/_HOME_/.config/pwa-buildpack.db' 41 | ); 42 | }); 43 | 44 | test('static async db() returns a Promise for a db', async () => { 45 | const fakeDb = await GlobalConfig.db(); 46 | expect(flatfile).toHaveBeenCalledWith('/_HOME_/.config/pwa-buildpack.db'); 47 | expect(mockDb.on).toHaveBeenCalledWith('open', expect.any(Function)); 48 | expect(fakeDb).toBe(mockDb); 49 | }); 50 | 51 | test('static async db() rejects if db open failed', async () => { 52 | mockDb.on.mockImplementationOnce((type, callback) => { 53 | if (type === 'error') { 54 | callback(new Error('Open failed')); 55 | } else if (type === 'open') { 56 | setImmediate(callback); 57 | } 58 | }); 59 | await expect(GlobalConfig.db()).rejects.toThrow('Open failed'); 60 | }); 61 | 62 | test('static async db() memoizes db and only creates it once', async () => { 63 | const fakeDb = await GlobalConfig.db(); 64 | const fakeDbAgain = await GlobalConfig.db(); 65 | expect(flatfile).toHaveBeenCalledTimes(1); 66 | expect(fakeDb).toBe(fakeDbAgain); 67 | }); 68 | 69 | test('static async db() rejects with underlying error if db create failed', async () => { 70 | flatfile.mockImplementationOnce(() => { 71 | throw Error('woah'); 72 | }); 73 | await expect(GlobalConfig.db()).rejects.toThrowError('woah'); 74 | }); 75 | 76 | test('GlobalConfig constructor throws if no conf or missing required conf', () => { 77 | expect(() => new GlobalConfig()).toThrow(); 78 | expect(() => new GlobalConfig({ key: x => x })).toThrow(); 79 | expect( 80 | () => new GlobalConfig({ key: 'bad key', prefix: 'good prefix' }) 81 | ).toThrow(); 82 | expect( 83 | () => new GlobalConfig({ key: () => {}, prefix: 'good prefix' }) 84 | ).toThrow(); 85 | expect(() => new GlobalConfig({ prefix: 'good prefix' })).not.toThrow(); 86 | expect( 87 | () => new GlobalConfig({ key: x => x, prefix: 'good prefix' }) 88 | ).not.toThrow(); 89 | }); 90 | 91 | test('.get() throws with wrong key arity', async () => { 92 | const cfg = new GlobalConfig({ prefix: 'test1', key: (a1, a2) => a2 }); 93 | expect(cfg.get('arg0')).rejects.toThrowError(/number of arguments/); 94 | }); 95 | 96 | test('.get() calls underlying flatfile', async () => { 97 | const cfg = new GlobalConfig({ prefix: 'test1' }); 98 | await cfg.get('value'); 99 | expect(crypto.createHash).toHaveBeenCalled(); 100 | expect(mockHash.update).toHaveBeenCalledWith('value'); 101 | expect(mockHash.digest).toHaveBeenCalled(); 102 | expect(mockDb.get).toHaveBeenCalledWith('test1fakeDigest'); 103 | }); 104 | 105 | test('.get() dies if key function returns non-string', async () => { 106 | const cfg = new GlobalConfig({ prefix: 'test1', key: x => null }); // eslint-disable-line 107 | expect(cfg.get('value')).rejects.toThrowError(/non\-string value/); 108 | }); 109 | 110 | test('.set() makes key from first arguments and value from last', async () => { 111 | const cfg = new GlobalConfig({ 112 | prefix: 'test1', 113 | key: (a1, a2, a3) => a1 + a2 + a3 114 | }); 115 | await cfg.set('a', 'b', 'c', 'd'); 116 | expect(mockHash.update).toHaveBeenCalledWith('abc'); 117 | expect(mockDb.put).toHaveBeenCalledWith( 118 | 'test1fakeDigest', 119 | 'd', 120 | expect.any(Function) 121 | ); 122 | }); 123 | 124 | test('.set() rejects with any errors passed from db', async () => { 125 | const cfg = new GlobalConfig({ 126 | prefix: 'test1', 127 | key: (a1, a2, a3) => a1 + a2 + a3 128 | }); 129 | mockDb.put.mockImplementationOnce((k, v, cb) => 130 | setImmediate(() => cb('bad put')) 131 | ); 132 | await expect(cfg.set('a', 'b', 'c', 'd')).rejects.toThrow('bad put'); 133 | }); 134 | 135 | test('.del() makes key and calls underlying db', async () => { 136 | const cfg = new GlobalConfig({ 137 | prefix: 'test1', 138 | key: (a1, a2, a3) => a1 + a2 + a3 139 | }); 140 | await cfg.del('a', 'b', 'c'); 141 | expect(mockHash.update).toHaveBeenCalledWith('abc'); 142 | expect(mockDb.del).toHaveBeenCalledWith( 143 | 'test1fakeDigest', 144 | expect.any(Function) 145 | ); 146 | }); 147 | 148 | test('.del() rejects with any errors passed from db', async () => { 149 | const cfg = new GlobalConfig({ 150 | prefix: 'test1', 151 | key: (a1, a2, a3) => a1 + a2 + a3 152 | }); 153 | mockDb.del.mockImplementationOnce((k, cb) => 154 | setImmediate(() => cb('bad del')) 155 | ); 156 | await expect(cfg.del('a', 'b', 'c')).rejects.toThrow('bad del'); 157 | }); 158 | 159 | test('.values() makes an array of all keys for this prefix and optionally xforms them', async () => { 160 | const cfg = new GlobalConfig({ 161 | prefix: 'test1', 162 | key: x => x 163 | }); 164 | mockDb.keys 165 | .mockReturnValueOnce(['otherNamespaceThing', 'test1foo1', 'test1foo2']) 166 | .mockReturnValueOnce(['test1foo1', 'test1foo2']); 167 | mockDb.get 168 | .mockReturnValueOnce('value1foo') 169 | .mockReturnValueOnce('value2foo') 170 | .mockReturnValueOnce('value1foo') 171 | .mockReturnValueOnce('value2foo'); 172 | const valuesOut = await cfg.values(); 173 | expect(valuesOut).toMatchObject(['value1foo', 'value2foo']); 174 | const valuesXformed = await cfg.values(x => x + 'x'); 175 | expect(valuesXformed).toMatchObject(['value1foox', 'value2foox']); 176 | }); 177 | 178 | test('.clear() calls underlying db', async () => { 179 | const cfg = new GlobalConfig({ 180 | prefix: 'test1', 181 | key: x => x 182 | }); 183 | await cfg.clear(); 184 | expect(mockDb.clear).toHaveBeenCalled(); 185 | }); 186 | 187 | test('.clear() rejects with any errors passed from db', async () => { 188 | const cfg = new GlobalConfig({ 189 | prefix: 'test1', 190 | key: x => x 191 | }); 192 | mockDb.clear.mockImplementationOnce(cb => cb('bad clear')); 193 | await expect(cfg.clear()).rejects.toThrow('bad clear'); 194 | }); 195 | -------------------------------------------------------------------------------- /src/util/__tests__/options-validator.spec.js: -------------------------------------------------------------------------------- 1 | const makeValidator = require('../options-validator'); 2 | 3 | test('creates a validation function from a schema', () => { 4 | expect(makeValidator()).toBeInstanceOf(Function); 5 | }); 6 | 7 | test('returns undefined if no problems', () => { 8 | const validate = makeValidator('Foo', { opt1: 'number' }); 9 | expect(validate('unittest', { opt1: 5 })).toBeUndefined(); 10 | }); 11 | 12 | test('throws formatted error if a key is missing', () => { 13 | const validate = makeValidator('Foo', { opt1: 'undefined' }); 14 | expect(() => validate('unittest', {})).toThrow(/Foo: Invalid/gim); 15 | expect(() => validate('unittest', {})).toThrow(/unittest was called/gim); 16 | expect(() => validate('unittest', {})).toThrow( 17 | /opt1 must be of type undefined/gim 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/util/__tests__/run-as-root.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('../promisified/child_process'); 2 | jest.mock('../promisified/fs'); 3 | 4 | const path = require('path'); 5 | const fs = require('../promisified/fs'); 6 | const { exec } = require('../promisified/child_process'); 7 | 8 | const implDir = path.resolve(__dirname, '..'); 9 | 10 | const runAsRoot = require('../run-as-root'); 11 | 12 | afterEach(jest.restoreAllMocks); 13 | 14 | test('serializes and writes a script to fs', async () => { 15 | exec.mockImplementationOnce(() => Promise.resolve({ stdout: '23' })); 16 | fs.writeFile.mockResolvedValueOnce(); 17 | fs.unlink.mockResolvedValueOnce(); 18 | await runAsRoot( 19 | 'Adding numbers requires temporary administrative privileges. \nEnter password for [%u]: ', 20 | (x, y) => x + y * 5, 21 | 3, 22 | 4 23 | ); 24 | expect(fs.writeFile).toHaveBeenCalledWith( 25 | expect.any(String), 26 | '((x, y) => x + y * 5)(...[3,4])', 27 | 'utf8' 28 | ); 29 | expect(path.dirname(fs.writeFile.mock.calls[0][0])).toBe(implDir); 30 | expect(eval(fs.writeFile.mock.calls[0][1])).toBe(23); 31 | }); 32 | 33 | test('runs sudo to call that script with a custom prompt', async () => { 34 | exec.mockImplementationOnce(() => Promise.resolve({ stdout: '23' })); 35 | fs.writeFile.mockResolvedValueOnce(); 36 | fs.unlink.mockResolvedValueOnce(); 37 | await runAsRoot( 38 | 'Adding numbers requires temporary administrative privileges. \nEnter password for [ %u ]: ', 39 | (x, y) => x + y * 5, 40 | 3, 41 | 4 42 | ); 43 | expect(exec).toHaveBeenCalledWith( 44 | expect.stringMatching( 45 | 'sudo -p \\"Adding numbers requires temporary administrative privileges' 46 | ) 47 | ); 48 | }); 49 | 50 | test('reports errors informatively', async () => { 51 | jest.spyOn(fs, 'writeFile').mockResolvedValue(); 52 | const error = new Error('object error message'); 53 | error.stdout = 'standard out'; 54 | error.stderr = 'standard error'; 55 | exec.mockImplementationOnce(() => Promise.reject(error)); 56 | jest.spyOn(fs, 'unlink').mockResolvedValue(); 57 | await expect( 58 | runAsRoot('Enter password for %u to run as %p on %H ', x => x) 59 | ).rejects.toThrowError( 60 | /object error message\s+standard error\s+standard out/m 61 | ); 62 | exec.mockImplementationOnce(() => Promise.reject('raw error message')); 63 | await expect(runAsRoot('Password: ', x => x)).rejects.toThrowError( 64 | /raw error message/ 65 | ); 66 | }); 67 | 68 | test('cleans up temp file on success or failure', async () => { 69 | fs.writeFile.mockResolvedValueOnce(); 70 | fs.unlink.mockResolvedValueOnce(); 71 | exec.mockImplementationOnce(() => Promise.resolve({ stdout: 'foo' })); 72 | await expect( 73 | runAsRoot('Enter password to log to console', () => console.log('foo')) 74 | ).resolves.toMatch('foo'); 75 | expect(fs.writeFile).toHaveBeenCalledTimes(1); 76 | expect(fs.unlink).toHaveBeenCalledTimes(1); 77 | await expect( 78 | runAsRoot('Enter password to do whatevs I guess', x => x) 79 | ).rejects.toThrowError(); 80 | expect(fs.writeFile).toHaveBeenCalledTimes(2); 81 | expect(fs.unlink).toHaveBeenCalledTimes(2); 82 | fs.writeFile.mock.calls.forEach((call, index) => 83 | expect(call[0]).toEqual(fs.unlink.mock.calls[index][0]) 84 | ); 85 | }); 86 | 87 | test('errors if first arg is not a prompt string', async () => { 88 | exec.mockImplementationOnce(i => Promise.resolve({ stdout: i })); 89 | await expect(runAsRoot(x => x, 5)).rejects.toThrowError( 90 | 'takes a prompt string as its first argument' 91 | ); 92 | }); 93 | 94 | test('errors if second arg is not a function', async () => { 95 | exec.mockImplementationOnce(i => Promise.resolve({ stdout: i })); 96 | await expect(runAsRoot('nothing')).rejects.toThrowError( 97 | 'takes a function as its second argument' 98 | ); 99 | }); 100 | -------------------------------------------------------------------------------- /src/util/__tests__/ssl-cert-store.spec.js: -------------------------------------------------------------------------------- 1 | jest.mock('../promisified/child_process'); 2 | jest.mock('../run-as-root'); 3 | jest.mock('../global-config'); 4 | 5 | const GlobalConfig = require('../global-config'); 6 | const { exec } = require('../promisified/child_process'); 7 | const runAsRoot = require('../run-as-root'); 8 | 9 | let SSLCertStore; 10 | beforeAll(() => { 11 | GlobalConfig.mockImplementation(({ key }) => ({ 12 | set: jest.fn((...args) => { 13 | const keyParts = args.slice(0, -1); 14 | expect(typeof key(...keyParts)).toBe('string'); 15 | }), 16 | get: jest.fn(), 17 | values: jest.fn(), 18 | del: jest.fn() 19 | })); 20 | SSLCertStore = require('../ssl-cert-store'); 21 | }); 22 | afterAll(() => GlobalConfig.mockRestore()); 23 | 24 | test('static async expired(cert) uses openssl to test whether a cert has expired or is expiring', async () => { 25 | exec.mockRejectedValueOnce({ 26 | stdout: '\nCertificate will expire\n' 27 | }); 28 | const shouldBeTrue = await SSLCertStore.expired('fakeCert'); 29 | expect(exec).toHaveBeenCalledWith( 30 | 'openssl x509 -checkend 0 <<< "fakeCert"' 31 | ); 32 | expect(shouldBeTrue).toBe(true); 33 | 34 | exec.mockResolvedValueOnce(null); 35 | const shouldBeFalse = await SSLCertStore.expired('fakeCert'); 36 | expect(shouldBeFalse).toBe(false); 37 | 38 | exec.mockRejectedValueOnce({ 39 | stdout: 'Some other value' 40 | }); 41 | const shouldStillBeFalse = await SSLCertStore.expired('fakeCert'); 42 | expect(shouldStillBeFalse).toBe(false); 43 | }); 44 | 45 | test('static async provide() throws on a non-string', async () => { 46 | await expect(SSLCertStore.provide(null)).rejects.toThrowError( 47 | 'Must provide a commonName' 48 | ); 49 | }); 50 | 51 | test('static async provide() gets a valid cached cert', async () => { 52 | exec.mockResolvedValueOnce(null); 53 | SSLCertStore.userCerts.get.mockResolvedValueOnce({ 54 | cert: 'cachedCert', 55 | key: 'cachedKey' 56 | }); 57 | await expect(SSLCertStore.provide('example.com')).resolves.toMatchObject({ 58 | cert: 'cachedCert', 59 | key: 'cachedKey' 60 | }); 61 | }); 62 | 63 | test('static async provide() deletes and recreates an expired cert', async () => { 64 | SSLCertStore.userCerts.get.mockResolvedValueOnce({ 65 | cert: 'expiredCert', 66 | key: 'expiredKey' 67 | }); 68 | exec.mockRejectedValueOnce({ stdout: 'Certificate will expire' }); 69 | runAsRoot.mockResolvedValueOnce( 70 | '{ "key": "refreshedKey", "cert": "refreshedCert" }' 71 | ); 72 | await expect(SSLCertStore.provide('example.com')).resolves.toMatchObject({ 73 | cert: 'refreshedCert', 74 | key: 'refreshedKey' 75 | }); 76 | }); 77 | 78 | test('static async provide() creates a cert for a fresh domain', async () => { 79 | SSLCertStore.userCerts.get.mockRestore(); 80 | runAsRoot.mockImplementationOnce(() => { 81 | return Promise.resolve('{ "key": "newKey", "cert": "newCert" }'); 82 | }); 83 | // and again with no cert, not even an expired one 84 | await expect(SSLCertStore.provide('existing.com')).resolves.toMatchObject({ 85 | cert: 'newCert', 86 | key: 'newKey' 87 | }); 88 | }); 89 | 90 | test('static async create() throws a formatted error if the root call did not work', async () => { 91 | runAsRoot.mockRejectedValueOnce(''); 92 | await expect(SSLCertStore.provide('example.com')).rejects.toThrowError( 93 | /generating dev cert/ 94 | ); 95 | }); 96 | -------------------------------------------------------------------------------- /src/util/debug.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const debug = require('debug'); 3 | const root = path.resolve(__dirname, '../'); 4 | const pkg = require(path.resolve(root, '../package.json')); 5 | const toolName = pkg.name.split('/').pop(); 6 | const makeTag = (...parts) => parts.join(':'); 7 | 8 | const taggedLogger = tag => { 9 | const logger = debug(tag); 10 | logger.errorMsg = msg => `[${tag}] ${msg}`; 11 | logger.sub = sub => taggedLogger(makeTag(tag, sub)); 12 | return logger; 13 | }; 14 | module.exports = { 15 | makeFileLogger(p) { 16 | const segments = path.relative(root, p).split(path.sep); 17 | if (segments[segments.length - 1] === 'index.js') { 18 | segments.pop(); 19 | } 20 | const tag = makeTag(toolName, ...segments); 21 | return taggedLogger(tag); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/util/global-config.js: -------------------------------------------------------------------------------- 1 | const debug = require('./debug').makeFileLogger(__filename); 2 | const { resolve } = require('path'); 3 | const { homedir } = require('os'); 4 | const { createHash } = require('crypto'); 5 | const flatfile = require('flat-file-db'); 6 | 7 | const toString = x => x.toString(); 8 | 9 | class GlobalConfig { 10 | static getDbFilePath() { 11 | return resolve(homedir(), './.config/pwa-buildpack.db'); 12 | } 13 | static async db() { 14 | if (!this._dbPromise) { 15 | this._dbPromise = new Promise((resolve, reject) => { 16 | try { 17 | const dbFilePath = this.getDbFilePath(); 18 | debug(`no cached db exists, pulling db from ${dbFilePath}`); 19 | const db = flatfile(dbFilePath); 20 | debug(`db created, waiting for open event`, db); 21 | db.on('error', reject); 22 | db.on('open', () => { 23 | debug('db open, fulfilling to subscribers'); 24 | resolve(db); 25 | }); 26 | } catch (e) { 27 | reject(e); 28 | } 29 | }); 30 | } 31 | return this._dbPromise; 32 | } 33 | constructor(options) { 34 | // validation 35 | if (typeof options !== 'object') { 36 | throw Error(debug.errorMsg('Must provide options.')); 37 | } 38 | 39 | const { key = toString, prefix } = options; 40 | 41 | if (typeof key !== 'function') { 42 | throw Error( 43 | debug.errorMsg('`key` function in options must be a function.') 44 | ); 45 | } 46 | 47 | if (key.length === 0) { 48 | throw Error( 49 | debug.errorMsg( 50 | 'Provided `key` function must take at least on argument.' 51 | ) 52 | ); 53 | } 54 | 55 | if (typeof prefix !== 'string') { 56 | throw Error( 57 | debug.errorMsg('Must provide a `prefix` string in options.') 58 | ); 59 | } 60 | 61 | this._makeKey = key; 62 | this._prefix = prefix; 63 | } 64 | makeKey(keyparts) { 65 | if (keyparts.length !== this._makeKey.length) { 66 | throw Error( 67 | `Wrong number of arguments sent to produce unique ${ 68 | this._prefix 69 | } key` 70 | ); 71 | } 72 | const hash = createHash('md5'); 73 | const key = this._makeKey(...keyparts); 74 | if (typeof key !== 'string') { 75 | throw Error( 76 | debug.errorMsg( 77 | `key function ${this._makeKey.toString()} returned a non-string value: ${key}: ${typeof key}` 78 | ) 79 | ); 80 | } 81 | hash.update(key); 82 | return this._prefix + hash.digest('hex'); 83 | } 84 | async get(...keyparts) { 85 | debug(`${this._prefix} get()`, keyparts); 86 | const db = await this.constructor.db(); 87 | const key = this.makeKey(keyparts); 88 | debug(`${this._prefix} get()`, keyparts, `made key: ${key}`); 89 | return db.get(key); 90 | } 91 | async set(...args) { 92 | debug(`${this._prefix} set()`, ...args); 93 | const db = await this.constructor.db(); 94 | const key = this.makeKey(args.slice(0, -1)); 95 | debug(`${this._prefix} set()`, args, `made key: ${key}`); 96 | return new Promise((resolve, reject) => 97 | db.put( 98 | key, 99 | args.slice(-1)[0], 100 | (error, response) => (error ? reject(error) : resolve(response)) 101 | ) 102 | ); 103 | } 104 | async del(...keyparts) { 105 | const db = await this.constructor.db(); 106 | const key = this.makeKey(keyparts); 107 | return new Promise((resolve, reject) => 108 | db.del( 109 | key, 110 | (error, response) => (error ? reject(error) : resolve(response)) 111 | ) 112 | ); 113 | } 114 | async values(xform = x => x) { 115 | const db = await this.constructor.db(); 116 | return db.keys().reduce((out, k) => { 117 | if (k.startsWith(this._prefix)) { 118 | out.push(xform(db.get(k))); 119 | } 120 | return out; 121 | }, []); 122 | } 123 | async clear() { 124 | const db = await this.constructor.db(); 125 | return new Promise((resolve, reject) => 126 | db.clear(error => (error ? reject(error) : resolve())) 127 | ); 128 | } 129 | } 130 | module.exports = GlobalConfig; 131 | -------------------------------------------------------------------------------- /src/util/options-validator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Quick and dirty substitute for a proper schematized validator like `joi`. 3 | * TODO [good first issue]: Replace with a more standard object shape validator. 4 | */ 5 | const lget = require('lodash.get'); 6 | const BLANK_DEFAULT = {}; 7 | 8 | class BuildpackValidationError extends Error { 9 | constructor(name, callsite, validationErrors) { 10 | super(); 11 | 12 | const bullet = '\n\t- '; 13 | this.name = 'BuildpackValidationError'; 14 | this.message = 15 | `${name}: Invalid configuration object. ` + 16 | `${callsite} was called with a configuration object that has the following problems:${bullet}` + 17 | validationErrors 18 | .map( 19 | ([key, requiredType]) => 20 | `${key} must be of type ${requiredType}` 21 | ) 22 | .join(bullet); 23 | this.validationErrors = validationErrors; 24 | 25 | Error.captureStackTrace(this, this.constructor); 26 | } 27 | } 28 | 29 | module.exports = (name, simpleSchema) => (callsite, options) => { 30 | const invalid = Object.entries(simpleSchema).reduce( 31 | (out, [key, requiredType]) => { 32 | const opt = lget(options, key, BLANK_DEFAULT); 33 | if (opt === BLANK_DEFAULT || typeof opt !== requiredType) { 34 | out.push([key, requiredType]); 35 | } 36 | return out; 37 | }, 38 | [] 39 | ); 40 | if (invalid.length > 0) { 41 | throw new BuildpackValidationError(name, callsite, invalid); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/util/promisified/child_process.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const child_process = require('child_process'); 3 | module.exports = { 4 | exec: promisify(child_process.exec) 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/promisified/dns.js: -------------------------------------------------------------------------------- 1 | const dns = require('dns'); 2 | const { promisify } = require('util'); 3 | module.exports = { 4 | lookup: promisify(dns.lookup) 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/promisified/fs.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const fs = require('fs'); 3 | module.exports = { 4 | readFile: promisify(fs.readFile), 5 | realpath: promisify(fs.realpath), 6 | stat: promisify(fs.stat), 7 | lstat: promisify(fs.lstat), 8 | symlink: promisify(fs.symlink), 9 | unlink: promisify(fs.unlink), 10 | writeFile: promisify(fs.writeFile) 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/promisified/openport.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const openport = require('openport'); 3 | module.exports = { 4 | find: promisify(openport.find) 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/run-as-root.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run sandboxed JavaScript as an administrator. 3 | * @module run-as-root 4 | */ 5 | 6 | const debug = require('./debug').makeFileLogger(__filename); 7 | const fs = require('./promisified/fs'); 8 | const { exec } = require('./promisified/child_process'); 9 | const { join } = require('path'); 10 | const escapeBashQuotes = str => str.split('"').join('"\'"\'"'); 11 | const tmp = () => 12 | join( 13 | __dirname, 14 | 'tmp' + 15 | Math.random() 16 | .toString(20) 17 | .slice(2) 18 | ); 19 | 20 | const sudoPromptToRunShell = async (prompt, cmd) => { 21 | debug(`running "sudo ${cmd}" now...`); 22 | try { 23 | const { stdout, stderr } = await exec( 24 | `sudo -p "${escapeBashQuotes(prompt)}" ${cmd}` 25 | ); 26 | return stdout + '\n\n' + stderr; 27 | } catch (e) { 28 | // Display all values present, 29 | // without a bunch of extra newlines 30 | const identity = x => x; 31 | const fullOutputForError = [e.message || e, e.stderr, e.stdout] 32 | .filter(identity) 33 | .join('\n\n'); 34 | 35 | const formattedError = new Error(fullOutputForError); 36 | 37 | formattedError.stdout = e.stdout; 38 | formattedError.stderr = e.stderr; 39 | 40 | throw formattedError; 41 | } 42 | }; 43 | 44 | /** 45 | * Prompts the user for an admin password, then runs its callback with 46 | * administrative privileges. 47 | * 48 | * Node should run as an unprivileged user most of the time, but while setting 49 | * up a workspace and doing system configuration, we might need root access to 50 | * do one or two things. Normally, you'd do that by creating a different script 51 | * file with the privileged code, and then create a child Node process under 52 | * sudo to run that script file: 53 | * 54 | * child_process.exec('sudo node ./different/script/file', callback) 55 | * 56 | * This prompts the user for a Sudo password in any TTY attached to the Node 57 | * process, and waits to run `callback` until the user has authorized or not. 58 | * 59 | * This function automates that process. 60 | * 61 | * 1. Stringifies its callback and saves it to a temp file 62 | * 2. Prompts user for credentials 63 | * 3. Runs the temp file with administrative privileges 64 | * 4. Returns a Promise that fulfills for the stdout of the script. 65 | * 66 | * **Warning:** The callback will run in a different process, and will not be 67 | * able to access any values in enclosed scope. If the function needs a value 68 | * from the current environment, pass it in through the `args` array and receive 69 | * it as a parameter. 70 | 71 | * @param {String} prompt Prompt message to display. [sudo -p](https://www.sudo.ws/man/1.8.17/sudo.man.html#p) 72 | * variables are interpolated from this string. 73 | * @param {Function} fn JavaScript code to run. Must be a function. It can take 74 | * arguments, which must be passed in order in an array to the following 75 | * `args` parameter. 76 | * @param {Array} args An array of values to be passed as arguments. Must be 77 | * serializable to JSON. 78 | * @returns {Promise} A promise for the console output of the 79 | * evaluated code. Rejects if the user did not authorize, or if the code 80 | * threw an exception. 81 | */ 82 | module.exports = async (prompt, func, ...args) => { 83 | if (typeof prompt !== 'string') { 84 | throw Error('runAsRoot takes a prompt string as its first argument.'); 85 | } 86 | if (typeof func !== 'function') { 87 | throw Error('runAsRoot takes a function as its second argument.'); 88 | } 89 | const codeText = func.toString(); 90 | const scriptLoc = tmp(); 91 | const invoked = `(${codeText})(...${JSON.stringify(args)})`; 92 | await fs.writeFile(scriptLoc, invoked, 'utf8'); 93 | debug(`elevating privileges for ${codeText}`); 94 | try { 95 | return await sudoPromptToRunShell( 96 | prompt, 97 | `${process.argv[0]} ${scriptLoc}` 98 | ); 99 | } finally { 100 | await fs.unlink(scriptLoc); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/util/ssl-cert-store.js: -------------------------------------------------------------------------------- 1 | const GlobalConfig = require('./global-config'); 2 | const debug = require('./debug').makeFileLogger(__filename); 3 | const { exec } = require('./promisified/child_process'); 4 | const runAsRoot = require('./run-as-root'); 5 | 6 | const userCerts = new GlobalConfig({ 7 | prefix: 'devcert', 8 | key: x => x 9 | }); 10 | 11 | module.exports = { 12 | userCerts, 13 | // treat a certificate as basically expired if it'll expire in 1 day (86400s) 14 | async expired(cert) { 15 | return exec(`openssl x509 -checkend 0 <<< "${cert}"`) 16 | .then(() => false) 17 | .catch(({ stdout }) => stdout.trim() === 'Certificate will expire'); 18 | }, 19 | async provide(commonName) { 20 | if (typeof commonName !== 'string') { 21 | throw Error( 22 | debug.errorMsg( 23 | `Must provide a commonName to SSLCertStore.provide(). Instead, argument was ${commonName}` 24 | ) 25 | ); 26 | } 27 | let certPair = await userCerts.get(commonName); 28 | if (certPair && (await this.expired(certPair.cert))) { 29 | certPair = null; 30 | await userCerts.del(commonName); 31 | } 32 | if (!certPair) { 33 | certPair = await this.create(commonName); 34 | await userCerts.set(commonName, certPair); 35 | } 36 | return certPair; 37 | }, 38 | async create(commonName) { 39 | try { 40 | return JSON.parse( 41 | await runAsRoot( 42 | 'Creating and trusting an SSL certificate for local dev requires temporary administrative privileges.\n Enter password for %u on %H: ', 43 | /* istanbul ignore next: this runs out of band in another process, hard to test */ 44 | async name => { 45 | const devcert = require('@magento/devcert'); 46 | const certs = await devcert(name); 47 | process.stdout.write(JSON.stringify(certs)); 48 | }, 49 | commonName 50 | ) 51 | ); 52 | } catch (e) { 53 | throw Error( 54 | debug.errorMsg( 55 | `Error generating dev certificate: ${e.message} ${e.stack}` 56 | ) 57 | ); 58 | } 59 | } 60 | }; 61 | --------------------------------------------------------------------------------