Repository: GoogleChromeLabs/gulliver Branch: main Commit: a8569469212f Files: 158 Total size: 839.8 KB Directory structure: gitextract_v5m2kdy5/ ├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gcloudignore ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── FAQ.md ├── LICENSE ├── README.md ├── app.js ├── app.yaml ├── config/ │ ├── .gitignore │ ├── config.example.json │ └── config.js ├── controllers/ │ ├── api/ │ │ ├── favorite-pwa.js │ │ ├── index.js │ │ ├── lighthouse.js │ │ ├── notifications.js │ │ └── pwa.js │ ├── app.js │ ├── cache.js │ ├── index.js │ ├── pwa.js │ ├── sw.js │ └── tasks.js ├── cron.yaml ├── firebase-messaging-sw-generator.js ├── firebase-messaging-sw.tmpl ├── index.yaml ├── lib/ │ ├── asset-hashing.js │ ├── color.js │ ├── data-cache.js │ ├── data-fetcher.js │ ├── event-bus.js │ ├── favorite-pwa.js │ ├── images.js │ ├── lighthouse.js │ ├── manifest.js │ ├── metadata.js │ ├── model-datastore.js │ ├── notifications.js │ ├── promise-sequential.js │ ├── pwa-index.js │ ├── pwa.js │ ├── search.js │ ├── tasks.js │ ├── verify-id-token.js │ └── web-performance.js ├── lighthouse_machine/ │ ├── .dockerignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── app.yaml │ ├── chromeuser-script.sh │ ├── cpu_monitor.js │ ├── entrypoint.sh │ ├── etc/ │ │ └── xvfb │ ├── package.json │ └── server.js ├── middlewares/ │ └── index.js ├── models/ │ ├── favorite-pwa.js │ ├── lighthouse.js │ ├── manifest.js │ ├── pwa.js │ ├── task.js │ └── user.js ├── package.json ├── public/ │ ├── .well-known/ │ │ └── assetlinks.json │ ├── css/ │ │ └── style.css │ ├── favicons/ │ │ └── browserconfig.xml │ ├── google3915c2aaf77f961f.html │ ├── humans.txt │ ├── js/ │ │ ├── analytics.js │ │ ├── chart.js │ │ ├── event-target.js │ │ ├── gapi.es6.js │ │ ├── gulliver-config.js │ │ ├── gulliver.es6.js │ │ ├── loader.js │ │ ├── messaging.js │ │ ├── offline-support.js │ │ ├── pwa-form.js │ │ ├── routing/ │ │ │ ├── route.js │ │ │ ├── router.js │ │ │ └── transitions.js │ │ ├── search-input.js │ │ ├── shell.js │ │ ├── signin.js │ │ ├── ui/ │ │ │ ├── notification-checkbox.js │ │ │ ├── share-button.js │ │ │ └── signin-button.js │ │ └── util/ │ │ └── requestIdleCallback.js │ ├── manifest.json │ ├── robots.txt │ └── sw.js ├── rollup-config/ │ ├── gulliver.js │ ├── lighthouse-chart.js │ └── pwa-form.js ├── test/ │ ├── app/ │ │ ├── controllers/ │ │ │ ├── api/ │ │ │ │ ├── favorite-pwa.js │ │ │ │ ├── lighthouse.js │ │ │ │ └── pwa.js │ │ │ ├── cache.js │ │ │ └── tasks.js │ │ ├── lib/ │ │ │ ├── asset-hashing.js │ │ │ ├── color.js │ │ │ ├── data-fetcher.js │ │ │ ├── favorite-pwa.js │ │ │ ├── images.js │ │ │ ├── lighthouse-example.json │ │ │ ├── lighthouse.js │ │ │ ├── manifest.js │ │ │ ├── model-datastore.js │ │ │ ├── notifications.js │ │ │ ├── promise-sequential.js │ │ │ ├── pwa.js │ │ │ ├── search.js │ │ │ └── tasks.js │ │ ├── manifests/ │ │ │ ├── icon-url-with-parameter.json │ │ │ ├── inline-image-large-content.json │ │ │ ├── invalid-theme-color.json │ │ │ └── no-icon-array.json │ │ ├── models/ │ │ │ └── pwa.js │ │ └── views/ │ │ └── helpers/ │ │ └── index.js │ └── client/ │ └── js/ │ └── event-target.js ├── third_party/ │ ├── Color.js │ ├── README.md │ ├── install.sh │ └── manifest-parser.js ├── tsconfig.json └── views/ ├── 404.hbs ├── app/ │ ├── offline.hbs │ └── shell.hbs ├── helpers/ │ └── index.js ├── includes/ │ ├── chevron_left.hbs │ ├── chevron_right.hbs │ ├── footer.hbs │ ├── head.hbs │ ├── header.hbs │ ├── hourglass.hbs │ ├── icon_log_in.hbs │ ├── icon_log_out.hbs │ ├── icon_search.hbs │ ├── icon_share.hbs │ ├── lighthouse.hbs │ ├── metadata.hbs │ ├── notifications_active.hbs │ ├── notifications_off.hbs │ ├── pagespeedinsight.hbs │ ├── pwadetails.hbs │ ├── score.hbs │ └── webpagetest.hbs └── pwas/ ├── form.hbs ├── list.hbs ├── view-rss.hbs └── view.hbs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ { "env": { "test": { "presets": ["es2015"] }, "default": { "presets": [ [ "es2015", { "modules": false } ] ], "plugins": ["external-helpers"] } } } ================================================ FILE: .eslintignore ================================================ /coverage /third_party /public/js/gulliver.js /public/js/lighthouse-chart.js /public/js/pwa-form.js /lighthouse_machine ================================================ FILE: .eslintrc.json ================================================ { "extends": "google", // http://eslint.org/docs/rules/ "rules": { "max-len": [2, 100, { "ignoreComments": true, "ignoreUrls": true, "tabWidth": 2 }], "no-implicit-coercion": [2, { "boolean": false, "number": true, "string": true }], "no-unused-expressions": [2, { "allowShortCircuit": true, "allowTernary": false }], "no-unused-vars": [2, { "vars": "all", "args": "after-used", "argsIgnorePattern": "(^reject$|^_$)", "varsIgnorePattern": "(^_$)" }], "quotes": [2, "single"], "require-jsdoc": 0, "valid-jsdoc": 0, "prefer-arrow-callback": 1, "no-var": 1 }, // http://eslint.org/docs/user-guide/configuring#specifying-environments "env": { "node": true }, "parserOptions": { "ecmaVersion": 2017 } } ================================================ FILE: .gcloudignore ================================================ node_modules/ lighthouse_machine/ ================================================ FILE: .gitignore ================================================ .DS_Store node_modules npm-debug.log /coverage .jshintrc .idea/ key.json public/js/gulliver.js public/js/gulliver.js.map public/firebase-messaging-sw.js ================================================ FILE: .travis.yml ================================================ language: node_js sudo: required dist: trusty node_js: - "8" before_script: - npm install script: - npm test env: # evade checks in config.js - CLIENT_ID=placeholder CLIENT_SECRET=placeholder GCLOUD_PROJECT=placeholder CLOUD_BUCKET=placeholder FIREBASE_AUTH=placeholder API_TOKENS="abcdefghijk" ================================================ FILE: CONTRIBUTING.md ================================================ Want to contribute? Great! First, read this page (including the small print at the end). ### Before you contribute Before we can use your code, you must sign the [Google Individual Contributor License Agreement] (https://cla.developers.google.com/about/google-individual) (CLA), which you can do online. The CLA is necessary mainly because you own the copyright to your changes, even after your contribution becomes part of our codebase, so we need your permission to use and distribute your code. We also need to be sure of various other things—for instance that you'll tell us if you know that your code infringes on other people's patents. You don't have to sign the CLA until after you've submitted your code for review and a member has approved it, but you must do it before we can put your code into our codebase. Before you start working on a larger contribution, you should get in touch with us first through the issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front makes it much easier to avoid frustration later on. ### Code reviews All submissions, including submissions by project members, require review. We use Github pull requests for this purpose. ### The small print Contributions made by corporations are covered by a different agreement than the one above, the [Software Grant and Corporate Contributor License Agreement] (https://cla.developers.google.com/about/google-corporate). ================================================ FILE: FAQ.md ================================================ # PWA Directory FAQ ### What is PWA Directory? Is an open source directory of Progressive Web Apps driven by user submissions. ### What are the goals of this project? Its goals are to help developers discover new PWAs, build a good example of a Server-Side Rendered PWA and share what we learn during the developing process. ### Is this a Google product? No, it was built by the Google Developer Relations team as an example for the Web developer community. ### How does it rank PWAs? We use [Lighthouse](https://github.com/GoogleChrome/lighthouse), that runs a set of checks validating the existence of the features, capabilities, and performance that should characterize a PWA. ### Why is my Lighthouse score different from the Lighthouse Chrome Extension? It is important to highlight that we use a version of Lighthouse built on [Headless Chromium](https://chromium.googlesource.com/chromium/src/+/master/headless/README.md) which enables it to run as a server app, for that reason our Lighthouse score and report may deviate from the standard Lighthouse Chrome extension. ### Why are you using Server Side Rendering? We found that there are not that many examples of PWAs using Server Side Rendering and that many developers would benefit from one. ### What technologies did you use? *Backend* - [Node.js](https://nodejs.org/en/) - [Express.js](http://expressjs.com/) - [Handlebars](http://handlebarsjs.com/) - [Google App Engine Node.js Flexible Environment](https://cloud.google.com/appengine/docs/flexible/nodejs/) *Frontend* - JavaScript (vanilla) - [Service Worker Precache](https://github.com/GoogleChrome/sw-precache) - [Service Worker Toolbox](https://github.com/GoogleChrome/sw-toolbox) *Storage* - [Google Cloud Datastore](https://cloud.google.com/datastore/) for general data - [Google Cloud Storage](https://cloud.google.com/storage/) for images only ### Why are you using Javascript without a framework? There is a good variety of JS frameworks out there and we love them, however we did not want to add extra overhead to developers that have not used the framework of our choice. ### What do you plan for the near future? We started with a basic example that we want to improve over time, our plan is to release a series of posts explaining in detail the discrete progressive enhancements from this basic Website to a high performing PWA. Beyond that, we want to track the evolution of all the PWAs submitted over time by running Lighthouse weekly, include newer metrics and features that will help developers test and build better PWAs. ###Why didn’t you just collaborate with other existing PWA directories? We wanted to start from scratch with a Server Side rendered solution and progressively add PWA functionalities to learn more about the process and document all the steps. ### How do I request features or submit bugs? Please submit them directly in our [GitHub issues section](https://github.com/GoogleChrome/gulliver/issues). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the 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. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015, Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ ```diff ! This project has been deprecated. ``` # Gulliver [Gulliver](https://pwa-directory.appspot.com/) is a directory of [Progressive Web Apps](https://infrequently.org/2016/09/what-exactly-makes-something-a-progressive-web-app/). ## Contents In Gulliver's landing page you can browse the set of currently registered PWAs as depicted in the following landing page snapshot: ![Screenshot](img/gulliver-landing-page.png) If you click on a particular PWA, Gulliver takes you to a detail page showing the results of an evaluation done on that specific PWA using the [Lighthouse PWA Analyzer](https://www.youtube.com/watch?v=KiV2p46rWjU) tool (Details page #1), and a view of the associated [web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) file for the application (Details Page #2): Details Page #1 | Details Page #2 :-------------------------:|:-------------------------: ![](img/gulliver-details-one.png) | ![](img/gulliver-details-two.png) Gulliver itself has been implemented as a PWA; therefore it is designed to work well on any kind of device, including desktop web browsers (see landing page), and on mobile devices (see details page). ## FAQ [Visit our FAQ Page](https://github.com/GoogleChrome/gulliver/blob/master/FAQ.md) ## Requirements Gulliver was built using the [ExpressJS](https://expressjs.com/) web framework for Node.js, and uses the [Google Cloud Platform](https://cloud.google.com/) (GCP) for computing and storage services. The following components are required to run the project (tested on macOS): 1. [NodeJS](https://nodejs.org/) (LTS version ~6.11.0). A JavaScript runtime built on Chrome's V8 JavaScript engine. (How to verify? Run `node --version`.) If you have a later version, install the LTS version with `nvm`. 1. [Google Cloud SDK](https://cloud.google.com/sdk/). A set of tools for the Google Cloud Platform (GCP) that you can use to access the Google Compute Engine and the Google Cloud Storage, which are two components of GCP used by Gulliver. (How to verify? Run `gcloud --version`.) 1. [Memcached](https://memcached.org/). A distributed memory object caching system. (How to verify? Run `memcached` (the command should appear to hang), and then `telnet localhost 11211` in a separate terminal. In the `telnet` window, typing `version` it should report the `memcached` version. If you don't have it, see [these instructions](https://cloud.google.com/appengine/docs/flexible/nodejs/using-redislabs-memcache#testing_memcached_locally) to install memcached.) In addition, you will need to set up a GCP project, and configure OAuth: 1. Create a [Google Cloud Platform](https://console.cloud.google.com/) project. A GCP project forms the basis of accessing the GCP. Then, run `gcloud init` to configure `gcloud` locally, if you get the error "Could not load the default credentials" run `gcloud auth login`. 1. Get the OAuth *client id* and *client secret* associated with this project. (How to verify? There's no automatic way, but see [Creating a Google API Console project and client ID](https://developers.google.com/identity/sign-in/web/devconsole-project) for how to create one. Make sure you list `http://localhost:8080` as one of the `Authorized JavaScript origins`.) Finally (and optionally), you need a Firebase project, and the Firebase Cloud Messaging "Server key" and "Sender ID": 1. Create a [Firebase](https://console.firebase.google.com/) project. 1. Get Firebase Cloud Messaging "Server key" and "Sender ID" associated with this project. Select "Project settings" and then "Cloud Messaging". The URL should be of the form . (How to verify? There's no automatic way, but the "Server key" should be a long string of >100 characters, and the "Sender ID" a >10 digit number.) ## Running Gulliver 1. Clone the GitHub repository: `git clone https://github.com/GoogleChrome/gulliver.git` 1. Switch into the project directory: `cd gulliver` 1. Create indexes for the [Google Cloud Datastore](https://cloud.google.com/datastore/docs/concepts/overview): `gcloud datastore create-indexes index.yaml` 1. (Optional) Deploy cron jobs for scheduled PWA updates: `gcloud app deploy cron.yaml` 1. Install **Memcached** and run it on `localhost:11211`. Check these [installation instructions](https://cloud.google.com/appengine/docs/flexible/nodejs/caching-application-data) for guidance. 1. Run **`npm install`** to install dependencies. 1. Configure your project either via a config file or environment variables (which override the corresponding keys in the config file). To create a config file, copy the [sample config](config/config.example.json) and adjust the values accordingly: ``` $ cp config/config.example.json config/config.json $ vim config/config.json ``` 1. Start Gulliver via `npm start`. 1. Gulliver should now be running at `http://localhost:8080`. ## Running Tests To verify that everything is working properly you can run the project's tests: 1. `npm test` to run lint + tests + coverage report. 2. `npm run mocha` to run all the tests only. 3. `npm run coverage` to run tests + coverage report. ## Lighthouse PWA Analyzer Gulliver reports an evaluation of the "progressiveness" of each registered PWA. This evaluation is done by Lighthouse, which is a tool that runs a set of checks validating the existence of the features, capabilities, and performance that should characterize a PWA. You can learn more about Lighthouse in the [GitHub repository](https://github.com/GoogleChrome/lighthouse), or in this [video](https://www.youtube.com/watch?v=KiV2p46rWjU). ## References To find out more about what PWAs are and how to go about incorporating the principles of PWAs into the development of your applications, check the following references which provide introductory information and references: + [Progressive Web Apps](https://developers.google.com/web/#progressive-web-apps): Documentation entry point. Here you will find several resources to get started developing PWAs + [Progressive Web Apps: Escaping Tabs without Losing our Soul](https://infrequently.org/2015/06/progressive-apps-escaping-tabs-without-losing-our-soul/): Introductory article with historical perspective + [Getting Started with Progressive Web Apps](https://addyosmani.com/blog/getting-started-with-progressive-web-apps/): Sound introduction on the fundamental elements behind the development of PWAs + [The Building Blocks of PWAs](https://www.smashingmagazine.com/2016/09/the-building-blocks-of-progressive-web-apps/): Interesting overall view of PWAs. ## License See [LICENSE](./LICENSE) for more. ## Disclaimer This is not a Google product. ================================================ FILE: app.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; // http-parser-js addresses issues such as corrupt HTTP headers // http://stackoverflow.com/questions/36628420/nodejs-request-hpe-invalid-header-token process.binding('http_parser').HTTPParser = require('http-parser-js').HTTPParser; const path = require('path'); const express = require('express'); const enforce = require('express-sslify'); const compression = require('compression'); const config = require('./config/config'); const asset = require('./lib/asset-hashing').asset; const hbs = require('hbs'); const helpers = require('./views/helpers'); const app = express(); const bodyParser = require('body-parser'); const serveStatic = require('serve-static'); const minifyHTML = require('express-minify-html'); const libPwaIndex = require('./lib/pwa-index'); const CACHE_CONTROL_SHORT_EXPIRES = 60 * 10; // 10 minutes. const CACHE_CONTROL_EXPIRES = 60 * 60 * 24; // 1 day. const CACHE_CONTROL_NEVER_EXPIRE = 31536000; const ENVIRONMENT_PRODUCTION = 'production'; if (app.get('env') === ENVIRONMENT_PRODUCTION) { app.use((req, res, next) => { if (req.path.startsWith('/tasks/')) { next(); } else { enforce.HTTPS({trustProtoHeader: true})(req, res, next); // eslint-disable-line new-cap } }); } app.use(compression()); app.disable('x-powered-by'); app.disable('etag'); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'hbs'); app.set('trust proxy', true); hbs.registerPartials(path.join(__dirname, '/views/includes/')); helpers.registerHelpers(hbs); // Make variables available to *all* templates hbs.localsAsTemplateData(app); app.locals.configstring = JSON.stringify({ /* eslint-disable camelcase */ client_id: config.get('CLIENT_ID'), ga_id: config.get('GOOGLE_ANALYTICS'), firebase_msg_sender_id: config.get('FIREBASE_MSG_SENDER_ID') /* eslint-enable camelcase */ }); app.use(bodyParser.urlencoded({extended: true})); if (app.get('env') === ENVIRONMENT_PRODUCTION) { app.use(minifyHTML({ override: true, exception_url: false, // eslint-disable-line camelcase htmlMinifier: { removeComments: true, collapseWhitespace: true, collapseBooleanAttributes: true, removeAttributeQuotes: true, removeEmptyAttributes: true, minifyJS: true } })); } // Static files const staticFilesMiddleware = serveStatic(path.resolve('./public')); app.use((req, res, next) => { const path = req.url; req.url = asset.decode(path); let mime = serveStatic.mime.lookup(req.url); if (mime.match('image*') || req.url.includes('manifest.json')) { res.setHeader('Cache-Control', 'public, max-age=' + CACHE_CONTROL_EXPIRES); } else if (req.url === path) { res.setHeader('Cache-Control', 'public, max-age=' + CACHE_CONTROL_SHORT_EXPIRES); } else { // versioned assets don't expire res.setHeader('Cache-Control', 'public, max-age=' + CACHE_CONTROL_NEVER_EXPIRE); } staticFilesMiddleware(req, res, next); }); // Make node_modules/{{module}} available at /{{module}} ['sw-toolbox', 'sw-offline-google-analytics'].forEach(module => { app.use( '/' + module, express.static('node_modules/' + module) ); }); // Middlewares app.use(require('./middlewares')); // Controllers app.use(require('./controllers')); // If no route has matched, return 404 app.use((req, res) => { res.status(404).render('404.hbs', {nonce1: req.nonce1, nonce2: req.nonce2, contentOnly: req.query.contentOnly || false}); }); // Basic error handler app.use((err, req, res, _) => { console.error(err); if (err.status === 404) { res.status(404).render('404.hbs', {nonce1: req.nonce1, nonce2: req.nonce2}); } else { // If our routes specified a specific response, then send that. Otherwise, // send a generic message so as not to leak anything. res.status(500).send(err || 'Something broke!'); } }); if (module === require.main) { // Start the server const server = app.listen(config.get('PORT'), () => { const port = server.address().port; console.log('App listening on port %s', port); }); // Index all PWAs libPwaIndex.indexAllPwas(); } module.exports = app; ================================================ FILE: app.yaml ================================================ # Copyright 2015-2016, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # runtime: nodejs env: flexible instance_class: B4_1G manual_scaling: instances: 2 handlers: - url: /.* script: IGNORED secure: always network: instance_tag: default-service ================================================ FILE: config/.gitignore ================================================ config.json ================================================ FILE: config/config.example.json ================================================ { "//": "See README.md for more information about what to put here", "GCLOUD_PROJECT": "run `gcloud config get-value project`", "CLOUD_BUCKET": "see https://console.cloud.google.com/storage/browser?project=$GCLOUD_PROJECT", "CLIENT_ID": "see https://console.cloud.google.com/apis/credentials?project=$GCLOUD_PROJECT", "CLIENT_SECRET": "see https://console.cloud.google.com/apis/credentials?project=$GCLOUD_PROJECT", "WEBPERFORMANCE_SERVER": "your Web Performance server URL (optional)", "WEBPERFORMANCE_SERVER_API_KEY": "your Key for the Web Performance Service", "GOOGLE_ANALYTICS": "your Google Analytics tracking code (optional)", "CANONICAL_ROOT": "your website root address. Can be http://localhost:8080 in development", "FIREBASE_AUTH": "the 'Server key' (optional); see https://console.firebase.google.com/project/$FIREBASE_PROJECT/settings/cloudmessaging", "FIREBASE_MSG_SENDER_ID": "the 'Sender ID' (optional); see https://console.firebase.google.com/project/$FIREBASE_PROJECT/settings/cloudmessaging" } ================================================ FILE: config/config.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; // Hierarchical node.js configuration with command-line arguments, environment // variables, and files. const nconf = require('nconf'); const path = require('path'); nconf // 1. Command-line arguments .argv() // 2. Environment variables .env([ 'CLOUD_BUCKET', 'GCLOUD_PROJECT', 'PORT', 'CLIENT_ID', 'CLIENT_SECRET', 'WEBPERFORMANCE_SERVER', 'WEBPERFORMANCE_SERVER_API_KEY', 'GOOGLE_ANALYTICS', 'FIREBASE_AUTH', 'CANONICAL_ROOT', 'FIREBASE_MSG_SENDER_ID', 'API_TOKENS' ]) // 3. Config file .file({file: path.join(__dirname, 'config.json')}) // 4. Defaults .defaults({ PORT: 8080 // Port used by HTTP server }); // Check for required settings checkConfig('GCLOUD_PROJECT'); checkConfig('CLOUD_BUCKET'); checkConfig('CLIENT_ID'); checkConfig('CLIENT_SECRET'); function checkConfig(setting) { // If setting undefined, throw error if (!nconf.get(setting)) { throw new Error(`You must set the ${setting} environment variable or add it to ` + 'config/config.json!'); } // If setting includes a space, throw error if (nconf.get(setting).match(/\s/)) { throw new Error(`The ${setting} environment variable is suspicious ("${nconf.get(setting)}")`); } } module.exports = nconf; ================================================ FILE: controllers/api/favorite-pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap const verifyIdToken = require('../../lib/verify-id-token'); const libFavoritePwa = require('../../lib/favorite-pwa'); const FavoritePwa = require('../../models/favorite-pwa'); const User = require('../../models/user'); /** * GET /favorite-pwa/ * * Returns all Favorite PWAs for a user */ router.get('/', (req, res) => { res.setHeader('Content-Type', 'application/json'); const idToken = req.get('Authorization'); if (!idToken) { res.status(401); res.json('401 Unauthorized'); return; } return verifyIdToken.verifyIdToken(idToken) .then(googleLogin => { const user = new User(googleLogin); return libFavoritePwa.findByUserId(user.id); }) .then(favoritePwas => { if (favoritePwas) { res.json(favoritePwas); } else { res.status(404); res.json('not found'); } }) .catch(err => { console.error(err); res.status(500); res.json('Server error while loading Favorite PWAs'); }); }); /** * GET /favorite-pwa/:pwaId * * Returns a Favorite PWA for a pwaId and user */ router.get('/:pwaId', (req, res) => { res.setHeader('Content-Type', 'application/json'); const pwaId = req.params.pwaId; const idToken = req.get('Authorization'); if (!idToken) { res.status(401); res.json('401 Unauthorized'); return; } return verifyIdToken.verifyIdToken(idToken) .then(googleLogin => { const user = new User(googleLogin); return libFavoritePwa.findFavoritePwa(pwaId, user.id); }) .then(favoritePwas => { if (favoritePwas) { res.json(favoritePwas); } else { res.status(404); res.json('not found'); } }) .catch(err => { console.error(err); res.status(500); res.json('Server error while loading Favorite PWAs'); }); }); /** * POST /favorite-pwa/ * * Create a Favorite PWA. */ router.post('/', (req, res) => { res.setHeader('Content-Type', 'application/json'); const idToken = req.body.idToken; const pwaId = req.body.pwaId; return verifyIdToken.verifyIdToken(idToken) .then(googleLogin => { const user = new User(googleLogin); return libFavoritePwa.save(new FavoritePwa(pwaId, user.id)); }) .then(favoritePwa => { if (favoritePwa) { res.json(favoritePwa); } else { res.status(404); res.json('not found'); } }) .catch(err => { console.error(err); res.status(500); res.json('Error creating Favorite PWA'); }); }); /** * DELETE /favorite-pwa/ * * Delete a Favorite PWA. */ router.delete('/', (req, res) => { res.setHeader('Content-Type', 'application/json'); const idToken = req.body.idToken; const pwaId = req.body.pwaId; return verifyIdToken.verifyIdToken(idToken) .then(googleLogin => { const user = new User(googleLogin); return libFavoritePwa.findFavoritePwa(pwaId, user.id); }) .then(favoritePwa => { if (favoritePwa) { libFavoritePwa.delete(favoritePwa.id) .then(_ => { res.status(200); res.json(favoritePwa); }) .catch(err => { console.error(err); res.status(500); res.json('Error deleting favorite PWA'); }); } else { res.status(404); res.json('not found'); } }) .catch(err => { console.error(err); res.status(500); res.json('Error deleting favorite PWA'); }); }); module.exports = router; ================================================ FILE: controllers/api/index.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap // Includes APIs for Lighthouse (/api/lighthouse) router.use('/lighthouse', require('./lighthouse')); // Includes APIs for Notifications (/api/notifications) router.use('/notifications', require('./notifications')); // Includes APIs for FavoritePwas (/api/favoritepwa) router.use('/favorite-pwa', require('./favorite-pwa')); // Includes APIs for PWAs (/api/pwa) router.use('/pwa', require('./pwa')); module.exports = router; ================================================ FILE: controllers/api/lighthouse.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const lighthouseLib = require('../../lib/lighthouse'); const router = express.Router(); // eslint-disable-line new-cap const CACHE_CONTROL_EXPIRES = 60 * 60 * 24; // 1 day. /** * GET /api/lighthouse-graph/:pwaId * * Returns the Lighthouse Graph information for a PWA * it uses the Google Charts JSON format: * https://developers.google.com/chart/interactive/docs/reference#dataparam */ router.get('/graph/:pwaId', (req, res) => { res.setHeader('Content-Type', 'application/json'); lighthouseLib.getLighthouseGraphByPwaId(req.params.pwaId) .then(lighthouseGraph => { if (lighthouseGraph) { res.setHeader('Cache-Control', 'public, max-age=' + CACHE_CONTROL_EXPIRES); res.json(lighthouseGraph); } else { res.status(404); res.json('not found'); } }) .catch(err => { res.status(500); res.json(err); }); }); module.exports = router; ================================================ FILE: controllers/api/notifications.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const bodyParser = require('body-parser'); const router = express.Router(); // eslint-disable-line new-cap const notificationsLib = require('../../lib/notifications'); const jsonParser = bodyParser.json(); router.get('/topics/', (req, res) => { const token = req.query.token; notificationsLib.list(token) .then(subscriptions => { res.json({ subscriptions: subscriptions }); }) .catch(err => { res.status(500); res.json(err); }); }); router.post('/subscribe/:topic/', jsonParser, (req, res) => { const token = req.body.token; const topic = req.params.topic; notificationsLib.subscribe(token, topic) .then(_ => { res.json({success: true}); }) .catch(err => { res.status(500); res.json(err); }); }); router.post('/unsubscribe/:topic/', jsonParser, (req, res) => { const token = req.body.token.trim(); const topic = req.params.topic; notificationsLib.unsubscribe(token, topic) .then(_ => { res.json({success: true}); }) .catch(err => { res.status(500); res.json(err); }); }); module.exports = router; ================================================ FILE: controllers/api/pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); require('express-csv'); const pwaLib = require('../../lib/pwa'); const libMetadata = require('../../lib/metadata'); const router = express.Router(); // eslint-disable-line new-cap const verifyIdToken = require('../../lib/verify-id-token'); const bodyParser = require('body-parser'); const Pwa = require('../../models/pwa'); const color = require('../../lib/color'); const CACHE_CONTROL_EXPIRES = 60 * 60 * 1; // 1 hour const RSS = require('rss'); const {URL} = require('url'); function getDate(date) { return new Date(date).toISOString().split('T')[0]; } class CsvWriter { write(result, pwas) { const csv = []; pwas.forEach(pwa => { const created = getDate(pwa.created); const updated = getDate(pwa.updated); const csvLine = []; csvLine.push(pwa.id); csvLine.push(pwa.absoluteStartUrl); csvLine.push(pwa.manifestUrl); csvLine.push(pwa.lighthouseScore); csvLine.push(created); csvLine.push(updated); csv.push(csvLine); }); result.setHeader('Content-Type', 'text/csv'); csv.unshift( ['id', 'absoluteStartUrl', 'manifestUrl', 'lighthouseScore', 'created', 'updated']); result.csv(csv); } } class JsonWriter { write(result, pwas) { const pwaList = []; pwas.forEach(dbPwa => { const created = getDate(dbPwa.created); const updated = getDate(dbPwa.updated); const pwa = {}; pwa.id = dbPwa.id; pwa.absoluteStartUrl = dbPwa.absoluteStartUrl; pwa.manifestUrl = dbPwa.manifestUrl; pwa.lighthouseScore = dbPwa.lighthouseScore; pwa.webPageTest = dbPwa.webPageTest; pwa.pageSpeed = dbPwa.pageSpeed; pwa.created = created; pwa.updated = updated; pwaList.push(pwa); }); result.setHeader('Content-Type', 'application/json'); result.json(pwaList); } } function render(res, view, options) { return new Promise((resolve, reject) => { res.render(view, options, (err, html) => { if (err) { console.log(err); reject(err); } resolve(html); }); }); } function renderOnePwaRss(pwa, req, res) { const url = req.originalUrl; const contentOnly = false || req.query.contentOnly; let arg = Object.assign(libMetadata.fromRequest(req, url), { pwa: pwa, title: 'PWA Directory: ' + pwa.name, description: 'PWA Directory: ' + pwa.name + ' - ' + pwa.description, backlink: true, contentOnly: contentOnly }); return render(res, 'pwas/view-rss.hbs', arg); } async function asyncForEach(array, callback) { for (let index = 0; index < array.length; index++) { await callback(array[index], index, array); } } class RssWriter { write(req, res, pwas) { const feed = new RSS({ /* eslint-disable camelcase */ title: 'PWA Directory', description: 'A Directory of Progressive Web Apps', feed_url: 'https://pwa-directory.appspot.com/api/pwa/?format=rss', site_url: 'https://pwa-directory.appspot.com/', image_url: 'https://pwa-directory.appspot.com/favicons/android-chrome-144x144.png', pubDate: new Date(), custom_namespaces: { rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', l: 'http://purl.org/rss/1.0/modules/link/', media: 'http://search.yahoo.com/mrss/', content: 'http://purl.org/rss/1.0/modules/content/' } }); const start = async _ => { await asyncForEach(pwas, async pwa => { let html = await renderOnePwaRss(pwa, req, res); const customElements = []; customElements.push({'content:encoded': html}); customElements.push({'l:link': {_attr: {'l:rel': 'http://purl.org/rss/1.0/modules/link/#alternate', 'l:type': 'application/json', 'rdf:resource': 'https://pwa-directory.appspot.com/api/pwa/' + pwa.id}}}); if (pwa.iconUrl128) { customElements.push({'media:thumbnail': {_attr: {url: pwa.iconUrl128, height: '128', width: '128'}}}); } feed.item({ title: pwa.displayName, url: 'https://pwa-directory.appspot.com/pwas/' + pwa.id, description: html, guid: pwa.id, date: pwa.created, custom_elements: customElements }); }); res.setHeader('Content-Type', 'application/rss+xml'); res.status(200).send(feed.xml()); }; start(); /* eslint-enable camelcase */ } } const csvWriter = new CsvWriter(); const jsonWriter = new JsonWriter(); const rssWriter = new RssWriter(); /** * GET /api/pwa * * Returns all PWAs as JSON, ?format=csv for CSV or ?format=rss for RSS feed */ router.get('/:id*?', (req, res) => { let format = req.query.format || 'json'; let sort = req.query.sort || 'newest'; let skip = parseInt(req.query.skip, 10); let limit = parseInt(req.query.limit, 10) || 100; res.setHeader('Cache-Control', 'public, max-age=' + CACHE_CONTROL_EXPIRES); let queryPromise = req.params.id ? pwaLib.find(req.params.id) : pwaLib.list(skip, limit, sort); queryPromise .then(result => { result = result.pwas ? result : {pwas: [result]}; switch (format) { case 'csv': { csvWriter.write(res, result.pwas); break; } case 'rss': { rssWriter.write(req, res, result.pwas); break; } default: { jsonWriter.write(res, result.pwas); } } }) .catch(err => { console.log(err); let code = err.code || 500; res.status(code); res.json(err); }); }); router.post('/add', bodyParser.json(), (req, res) => { const idToken = req.body.idToken; if (!idToken) { res.status(400).send({error: 'user not logged in'}); return; } const manifestUrl = req.body.manifestUrl; if (!manifestUrl) { res.status(400).send({error: 'no manifest provided'}); return; } try { const url = new URL(manifestUrl); (async () => { try { const pwa = new Pwa(url.toString()); const user = await verifyIdToken.verifyIdToken(idToken); pwa.setUser(user); const savedPwa = await pwaLib.createOrUpdatePwa(pwa); res.json({ id: savedPwa.id, name: savedPwa.name, backgroundColor: savedPwa.backgroundColor, foregroundColor: color.bestContrastRatio('#FFFFFF', '#000000', savedPwa.backgroundColor) }); } catch (e) { const message = e.message || e; res.status(400).json({error: message}); } })(); } catch (e) { res.status(400).send({error: 'manifestUrl is not an URL'}); } }); module.exports = router; ================================================ FILE: controllers/app.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap router.get('/shell', (req, res) => { res.render('app/shell.hbs'); }); router.get('/offline', (req, res, next) => { // eslint-disable-line no-unused-vars res.render('app/offline.hbs'); }); module.exports = router; ================================================ FILE: controllers/cache.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap const libCache = require('../lib/data-cache'); const CACHE_LIFETIME = 60 * 60; // 1 hour /** * GET * * * Serves cached HTML or * overrides res.send to be able to cache rendered HTML before sending. */ router.get('*', (req, res, next) => { const url = req.originalUrl; libCache.get(url) .then(cachedHtml => { console.log('From cache: ' + url); res.send(cachedHtml); }) .catch(_ => { // Overrides res.send to be able to cache before sending. res.sendResponse = res.send; res.send = body => { libCache.set(url, body, CACHE_LIFETIME) .then(_ => { libCache.storeCachedUrls(url); console.log('Stored in cache: ' + url); }) .catch(_ => { console.log('Error setting cache for: ' + url); }); res.sendResponse(body); }; next(); }); }); module.exports = router; ================================================ FILE: controllers/index.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap const config = require('../config/config'); const bodyParser = require('body-parser'); router.use(bodyParser.json()); // API router.use('/api', require('./api')); // Tasks router.use('/tasks', require('./tasks')); // PWAs router.use('/pwas', require('./pwa')); router.get('/', (req, res) => { req.url = '/pwas'; router.handle(req, res); }); router.get('/installable', (req, res) => { req.url = '/pwas/installable'; router.handle(req, res); }); // ServiceWorker router.use('/js', require('./sw')); // /.shell hosts app shell dependencies router.use('/.app', require('./app')); /** * This route is used to send config.json to firebase-messaging-sw.js */ router.get('/messaging-config.json', (req, res) => { // eslint-disable-next-line camelcase res.json({firebase_msg_sender_id: config.get('FIREBASE_MSG_SENDER_ID')}); }); module.exports = router; ================================================ FILE: controllers/pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const dataFetcher = require('../lib/data-fetcher'); const pwaLib = require('../lib/pwa'); const libPwaIndex = require('../lib/pwa-index'); const verifyIdToken = require('../lib/verify-id-token'); const lighthouseLib = require('../lib/lighthouse'); const Pwa = require('../models/pwa'); const router = express.Router(); // eslint-disable-line new-cap const libMetadata = require('../lib/metadata'); const LIST_PAGE_SIZE = 32; const DEFAULT_PAGE_NUMBER = 1; const DEFAULT_SORT_ORDER = 'newest'; const DEFAULT_TAB = 'installable'; const DEFAULT_FILTER = { installable: true }; /** * Setup the list template view state */ function setupListViewState(req) { const viewState = {}; if (typeof req.query.query === 'undefined') { viewState.mainPage = true; viewState.backlink = false; viewState.search = false; } else { viewState.mainPage = true; viewState.backlink = true; viewState.search = true; viewState.searchQuery = req.query.query; } viewState.contentOnly = false || req.query.contentOnly; viewState.pageNumber = parseInt(req.query.page, 10) || DEFAULT_PAGE_NUMBER; viewState.sortOrder = req.query.sort || DEFAULT_SORT_ORDER; viewState.start = parseInt(req.query.start, 10) || (viewState.pageNumber - 1) * LIST_PAGE_SIZE; viewState.limit = parseInt(req.query.limit, 10) || LIST_PAGE_SIZE; viewState.end = viewState.pageNumber * LIST_PAGE_SIZE; return viewState; } /** * Setup the list template view arguments */ function setupListViewArguments(req, viewState, result) { return Object.assign(libMetadata.fromRequest(req), { title: 'PWA Directory', description: 'PWA Directory: A Directory of Progressive Web Apps', pwas: result.pwas, hasNextPage: result.hasMore, hasPreviousPage: viewState.pageNumber > 1, nextPageNumber: viewState.pageNumber + 1, previousPageNumber: (viewState.pageNumber === 2) ? false : viewState.pageNumber - 1, currentPageNumber: viewState.pageNumber, sortOrder: (viewState.sortOrder === DEFAULT_SORT_ORDER) ? false : viewState.sortOrder, currentTab: req.path.substring(1, req.path.length) || DEFAULT_TAB, startPwa: viewState.start + 1, mainPage: viewState.mainPage, search: viewState.search, backlink: viewState.backlink, searchQuery: viewState.searchQuery, contentOnly: viewState.contentOnly }); } function listPwas(req, res, next, sortOrder, filters) { const viewState = setupListViewState(req); pwaLib.list(viewState.start, viewState.limit, sortOrder, filters) .then(result => render(res, 'pwas/list.hbs', setupListViewArguments(req, viewState, result))) .then(html => res.send(html)) .catch(err => { err.status = 500; next(err); }); } /** * GET / * * Display a page of PWAs (up to LIST_PAGE_SIZE at a time) */ router.get('/', (req, res, next) => { listPwas(req, res, next, DEFAULT_SORT_ORDER, DEFAULT_FILTER); }); /** * GET /newest * * Display a page of PWAs sorted by score. */ router.get('/newest', (req, res, next) => { listPwas(req, res, next, 'newest'); }); /** * GET /score * * Display a page of PWAs sorted by score. */ router.get('/score', (req, res, next) => { listPwas(req, res, next, 'score'); }); /** * GET /installable * * Display a page of installable PWAs. */ router.get('/installable', (req, res, next) => { const filters = { installable: true }; listPwas(req, res, next, 'newest', filters); }); /** * GET /pwas/search * * Display a search result page of PWAs */ router.get('/search', (req, res, next) => { const viewState = setupListViewState(req); libPwaIndex.searchPwas(viewState.searchQuery).then(result => render(res, 'pwas/list.hbs', setupListViewArguments(req, viewState, result))) .then(html => res.send(html)) .catch(err => { err.status = 500; next(err); }); }); /** * GET /pwas/add * * Display a form for creating a PWA. */ router.get('/add', async (req, res, next) => { try { const contentOnly = req.query.contentOnly || false; const url = req.query.url || ''; let manifestUrl = req.query.manifestUrl || ''; if (url !== '' && manifestUrl !== '') { const err = new Error('only one of url or manifestUrl may be set'); err.status = 400; throw err; } if (url !== '') { manifestUrl = await dataFetcher.fetchLinkRelManifestUrl(url); } let arg = Object.assign(libMetadata.fromRequest(req), { title: 'PWA Directory - Submit a PWA', description: 'PWA Directory: Submit a Progressive Web Apps', pwa: {}, action: 'Add', backlink: true, submit: true, contentOnly, manifestUrl }); res.render('pwas/form.hbs', arg); } catch (err) { next(err); } }); /** * POST /pwas/add * * Create a PWA. */ router.post('/add', (req, res, next) => { let manifestUrl = req.body.manifestUrl.trim(); if (manifestUrl.startsWith('http://')) { manifestUrl = manifestUrl.replace('http://', 'https://'); } const idToken = req.body.idToken; let pwa = new Pwa(manifestUrl); if (!manifestUrl || !idToken) { let arg = Object.assign(libMetadata.fromRequest(req), { pwa, backlink: true, error: (manifestUrl) ? 'user not logged in' : 'no manifest provided' }); res.render('pwas/form.hbs', arg); return; } verifyIdToken.verifyIdToken(idToken) .then(user => { pwa.setUser(user); return pwaLib.createOrUpdatePwa(pwa); }) .then(savedData => { res.redirect(req.baseUrl + '/' + savedData.id); return; }) .catch(err => { if (typeof err === 'number') { switch (err) { case pwaLib.E_MANIFEST_INVALID_URL: err = `pwa.manifestUrl [${pwa.manifestUrl}] is not a valid URL`; break; case pwaLib.E_MISING_USER_INFORMATION: err = 'Missing user information'; break; case pwaLib.E_MANIFEST_URL_MISSING: err = 'Missing manifestUrl'; break; case pwaLib.E_NOT_A_PWA: err = 'pwa is not an instance of Pwa'; break; default: return next(err); } } // Transform err from an array of strings (in a particular format) to a // comma-separated string. if (Array.isArray(err)) { const s = err.map(e => { const m = e.match(/^ERROR:\s+(.*)\.$/); return m ? m[1] : e; // if no match (format changed?), just return the string }).join(', '); err = s; } let arg = Object.assign(libMetadata.fromRequest(req), { pwa, backlink: true, error: err }); res.render('pwas/form.hbs', arg); return; }); }); /** * GET /pwas/:id * * Display a PWA or redirects to the encodedStartUrl of the PWA. */ router.get('/:pwa', (req, res, next) => { renderOnePwa(req, res) .then(html => { res.send(html); }) .catch(err => { err.status = 404; return next(err); }); return; }); /** * Generate the HTML with 'pwas/view.hbs' for one PWA */ function renderOnePwa(req, res) { const url = req.originalUrl; const pwaId = encodeURIComponent(req.params.pwa); // we have foo/ here, need foo%2F const contentOnly = false || req.query.contentOnly; return pwaLib.find(pwaId) .then(pwa => { return lighthouseLib.findByPwaId(pwaId) .then(lighthouse => { if (lighthouse && lighthouse.lighthouseInfo && Object.prototype.toString.call(lighthouse.lighthouseInfo) === '[object String]') { lighthouse.lighthouseInfo = JSON.parse(lighthouse.lighthouseInfo); } let arg = Object.assign(libMetadata.fromRequest(req, url), { pwa: pwa, lighthouse: lighthouse, rawManifestJson: JSON.parse(pwa.manifest.raw), title: 'PWA Directory: ' + pwa.name, description: 'PWA Directory: ' + pwa.name + ' - ' + pwa.description, backlink: true, contentOnly: contentOnly }); return render(res, 'pwas/view.hbs', arg); }); }); } /** * res.render as a Promise */ function render(res, view, options) { return new Promise((resolve, reject) => { res.render(view, options, (err, html) => { if (err) { console.log(err); reject(err); } resolve(html); }); }); } /** * Errors on "/pwas/*" routes. */ router.use((err, req, res, next) => { // Format error and forward to generic error handler for logging and // responding to the request err.response = err.message; console.error(err); next(err); }); module.exports = router; ================================================ FILE: controllers/sw.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const router = express.Router(); // eslint-disable-line new-cap const asset = require('../lib/asset-hashing').asset; const ASSETS = JSON.stringify([ '/css/style.css', '/js/gulliver.js' ].map(assetPath => asset.encode(assetPath))); const ASSETS_JS = `const ASSETS = ${ASSETS};`; router.get('/sw-assets-precache.js', (req, res) => { res.setHeader('Content-Type', 'application/javascript'); res.setHeader('Cache-Control', 'no-cache, max-age=0'); res.send(ASSETS_JS); }); module.exports = router; ================================================ FILE: controllers/tasks.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const pwaLib = require('../lib/pwa'); const tasksLib = require('../lib/tasks'); const Task = require('../models/task'); const router = express.Router(); // eslint-disable-line new-cap const APP_ENGINE_CRON = 'X-Appengine-Cron'; /** * Checks for the presence of the 'X-Appengine-Cron' header on the request. * Only requests from the App Engine cron are allowed. */ function checkAppEngineCron(req, res, next) { if (!req.get(APP_ENGINE_CRON)) { return res.sendStatus(403); } return next(); } /** * Creates a pwaLib.createOrUpdatePwa task for the given pwaId */ function createOrUpdatePwaTasks(pwaList) { const modulePath = require.resolve('../lib/pwa'); pwaList.forEach(pwa => { tasksLib.push(new Task(pwa.id, modulePath, 'createOrUpdatePwa', 0)); }); } /** * GET /tasks/cron * * We use a GET from the cron job to launch a PWA update process * for all PWAs. * * Uses checkAppEngineCron to allow only request from cron job. */ router.get('/cron', checkAppEngineCron, (req, res, next) => { pwaLib.list(undefined, undefined, 'newest') .then(result => { // Create one update task for each PWA createOrUpdatePwaTasks(result.pwas); res.sendStatus(200); }) .catch(err => { next(err); }); }); /** * GET /tasks/updateunscored * * We use a GET from the cron job to launch a PWA update process * for all PWAs. * * Uses checkAppEngineCron to allow only request from cron job. */ router.get('/updateunscored', checkAppEngineCron, (req, res, next) => { return pwaLib.list(undefined, undefined, 'newest') .then(result => { // Create one update task for each unscored PWA createOrUpdatePwaTasks(result.pwas.filter(pwa => !pwa.lighthouseScore)); res.sendStatus(200); }) .catch(err => { next(err); }); }); /** * GET /tasks/execute?tasks=1 * * We use a GET from the cron job to execute each PWA update task * The tasks parameter is the number of tasks to execute per run * * Uses checkAppEngineCron to allow only request from cron job. */ router.get('/execute', checkAppEngineCron, (req, res) => { const tasksToExecute = req.query.tasks ? req.query.tasks : 1; // const tasksList = []; (async () => { const tasks = await tasksLib.getTasks(tasksToExecute); console.log(`Executing ${tasks.length} tasks`); for (let task of tasks) { try { console.log(`Will Execute Task: ${task.id}`); // Delete before executing, so we ensure that if the task breaks // something it is removed from the queue anyway. try { await tasksLib.deleteTask(task.id); } catch (err) { console.error(`Error deleting task: ${task.id}`); } await tasksLib.executePwaTask(task); console.log(`Executed Task: ${task.id}`); } catch (err) { console.error(`Failed to execute task: ${task.id}`); } } res.sendStatus(200); })(); }); /** * Errors on "/task/*" routes. */ router.use((err, req, res, next) => { // Format error and forward to generic error handler for logging and // responding to the request err.response = { message: err.message, internalCode: err.code }; next(err); }); module.exports = router; ================================================ FILE: cron.yaml ================================================ cron: - description: (Node) Daily PWA info update job url: /tasks/cron schedule: every day 13:00 - description: (Node) Execute PWA update tasks url: /tasks/execute?tasks=30 schedule: every 1 minutes - description: (Node) Update unscored PWAs url: /tasks/updateunscored schedule: every 1 hours - description: Update unscored PWAs url: /taskcreator/task?unscored=true schedule: every 1 hours target: web-performance - description: UpdateManifestTask url: /taskcreator/task/UpdateManifestTask schedule: every day 16:00 target: web-performance - description: UpdateIconTask url: /taskcreator/task/UpdateIconTask schedule: every monday 01:00 target: web-performance - description: PageSpeedReportTask url: /taskcreator/task/PageSpeedReportTask schedule: every friday 01:00 target: web-performance - description: WebPageTestReportTask url: /taskcreator/task/WebPageTestReportTask schedule: every sunday 01:00 target: web-performance ================================================ FILE: firebase-messaging-sw-generator.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const fs = require('fs'); const template = require('lodash.template'); const config = require('./config/config'); const firebaseMsgSenderId = config.get('FIREBASE_MSG_SENDER_ID'); fs.readFile('./firebase-messaging-sw.tmpl', 'utf8', (error, data) => { if (error) { console.error('Error reading template: ', error); return; } const firebaseMessagingSwFileContent = template(data)({ firebaseMsgSenderId: firebaseMsgSenderId }); fs.writeFile('./public/firebase-messaging-sw.js', firebaseMessagingSwFileContent, err => { if (err) { console.log('Error Writing firebase-messaging-sw: ', err); } }); }); ================================================ FILE: firebase-messaging-sw.tmpl ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env serviceworker, browser */ /* global firebase */ importScripts('https://www.gstatic.com/firebasejs/3.5.2/firebase-app.js'); importScripts('https://www.gstatic.com/firebasejs/3.5.2/firebase-messaging.js'); firebase.initializeApp({ messagingSenderId: '<%= firebaseMsgSenderId %>' }); const messaging = firebase.messaging(); messaging.setBackgroundMessageHandler(_ => { return self.registration.showNotification(); }); ================================================ FILE: index.yaml ================================================ indexes: - kind: Lighthouse properties: - name: pwaId direction: asc - name: date direction: desc - kind: PWA properties: - name: installable - name: lighthouseScore direction: desc - kind: PWA properties: - name: installable - name: created direction: desc ================================================ FILE: lib/asset-hashing.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const fs = require('fs'); const path = require('path'); const revHash = require('rev-hash'); const CHECKSUM_LENGTH = 10; const CHECKSUM_PATTERN = /^[0-9a-z]{10}$/; class ChecksumProvider { constructor(root) { this.root_ = root; } get(assetPath) { const buffer = fs.readFileSync(path.join(this.root_, assetPath)); return revHash(buffer); } } class AssetChecksum { constructor(checksumProvider) { this.checksumProvider_ = checksumProvider; this.checksumCache_ = {}; } encode(assetPath) { if (!assetPath) { return assetPath; } let result = this.checksumCache_[assetPath]; if (result) { return result; } const checksum = this.checksumProvider_.get(assetPath); const index = assetPath.lastIndexOf('.'); if (index === -1) { return assetPath; } result = assetPath.substring(0, index) + '.' + checksum + assetPath.substring(index, assetPath.length); this.checksumCache_[assetPath] = result; return result; } decode(assetPath) { if (!assetPath) { return assetPath; } const fragments = assetPath.split('.'); if (fragments.length <= 1) { return assetPath; } const checksumIndex = fragments.length - 2; if (!fragments[checksumIndex].match(CHECKSUM_PATTERN)) { return assetPath; } fragments.splice(checksumIndex, 1); return fragments.join('.'); } } module.exports.asset = new AssetChecksum(new ChecksumProvider('public')); // Exported for testing module.exports.ChecksumProvider = ChecksumProvider; module.exports.AssetChecksum = AssetChecksum; module.exports.CHECKSUM_LENGTH = CHECKSUM_LENGTH; ================================================ FILE: lib/color.js ================================================ /** * Copyright 2015-2018, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const parseColor = require('parse-color'); function bestContrastRatio(color1, color2, background) { return contrastRatio(color1, background) > contrastRatio(color2, background) ? color1 : color2; } /** * Calculates the contrast ratio, as described on https://www.w3.org/TR/WCAG20/#contrast-ratiodef * * @param {string} foreground the foreground color. * @param {string} background the background color, Defaults to #FFFFFF. * @returns {Number} the contrast ration. */ function contrastRatio(foreground, background = '#FFFFFF') { if (background.trim() === 'transparent') background = 'white'; const bgLuminance = relativeLuminance(background); const fgLuminance = relativeLuminance(foreground); let darker; let lighter; if (fgLuminance > bgLuminance) { lighter = fgLuminance; darker = bgLuminance; } else { lighter = bgLuminance; darker = fgLuminance; } return (lighter + 0.05) / (darker + 0.05); } /** * Calculates the relative luminance, as described on https://www.w3.org/TR/WCAG20/#relativeluminancedef * * @param {string} color the foreground color. * @returns {Number} the relative luminance. */ function relativeLuminance(color) { let colorRed; let colorGreen; let colorBlue; try { [colorRed, colorGreen, colorBlue] = parseColor(color.trim()).rgb; } catch (error) { throw new Error('Error parsing Color with parseColor' + error); } let red = componentRelativeLuminance_(colorRed); let green = componentRelativeLuminance_(colorGreen); let blue = componentRelativeLuminance_(colorBlue); return 0.2126 * red + 0.7152 * green + 0.0722 * blue; } /** * Generates the luminance of a single color component. * @param {Number} component the value to have the luminance calculated * @returns {Number} the calculated luminance of the color component. */ function componentRelativeLuminance_(component) { let c = component / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } module.exports = { contrastRatio: contrastRatio, relativeLuminance: relativeLuminance, bestContrastRatio: bestContrastRatio }; ================================================ FILE: lib/data-cache.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const Memcached = require('memcached'); const memcachedAddr = process.env.MEMCACHE_PORT_11211_TCP_ADDR || config.get('MEMCACHED_SERVER') || 'localhost'; const memcachedPort = process.env.MEMCACHE_PORT_11211_TCP_PORT || '11211'; const memcached = new Memcached(memcachedAddr + ':' + memcachedPort, {timeout: 600, retries: 1}); const PAGELIST_URLS = 'PAGELIST_URLS'; const CACHE_LIFETIME = 60 * 60 * 6; // 6 hours /** * Gets a value from memcached using. * * @param {object} a key. * @returns a Promise */ function get(key) { return new Promise((resolve, reject) => { memcached.get(key, (err, value) => { if (err) { return reject(err); } if (!value) { return reject('Not Found. Key: ' + key); } return resolve(value); }); }); } /** * Deletes a value from memcached. * * @param {object} a key. * @returns a Promise */ function del(key) { return new Promise((resolve, reject) => { memcached.del(key, (err, value) => { if (err) { return reject(err); } if (!value) { return reject('Not Found. Key: ' + key); } return resolve(value); }); }); } /** * Sets a value in Memcached. * * @param {object} the key. * @param {object} the value. * @param {Number} a lifetime. * @returns a Promise */ function set(key, value, lifetime) { return new Promise((resolve, reject) => { memcached.set(key, value, lifetime, err => { if (err) { return reject(err); } return resolve(); }); }); } /** * Replaces a value in Memcached. * * @param {object} the key. * @param {object} the value. * @param {Number} a lifetime. * @returns a Promise */ function replace(key, value, lifetime) { return new Promise((resolve, reject) => { memcached.replace(key, value, lifetime, err => { if (err) { console.error(err); return reject(err); } return resolve(); }); }); } function getMulti(keys) { return new Promise((resolve, reject) => { memcached.getMulti(keys, (err, data) => { if (err) { console.error(err); return reject(err); } return resolve(data); }); }); } /** * Flushes memcache */ function flush() { memcached.flush(); } /** * Stores URLs in PAGELIST_URLS that need to be removed from cache. */ function storeCachedUrls(url) { // Stores list and search pages if (url.indexOf('/pwas/') === -1 || url.indexOf('/pwas/search') >= 0) { this.get(PAGELIST_URLS) .then(array => { let urlSet = new Set(array); urlSet.add(url); this.set(PAGELIST_URLS, Array.from(urlSet), CACHE_LIFETIME); }) .catch(_ => { let urlSet = new Set(); urlSet.add(url); this.set(PAGELIST_URLS, Array.from(urlSet), CACHE_LIFETIME); }); } } /** * Flush URLs from PAGELIST_URLS list. */ function flushCacheUrls() { this.get(PAGELIST_URLS) .then(array => { array.forEach(url => { this.del(url); }); this.del(PAGELIST_URLS); }) .catch(err => { console.error('Error flushing cache URLs: ', err); }); } module.exports = { get: get, del: del, set: set, replace: replace, flush: flush, getMulti: getMulti, storeCachedUrls: storeCachedUrls, flushCacheUrls: flushCacheUrls }; ================================================ FILE: lib/data-fetcher.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const URL = require('url'); const fetch = require('node-fetch'); const fs = require('fs'); const cheerio = require('cheerio'); const config = require('../config/config'); const FIREBASE_AUTH = config.get('FIREBASE_AUTH'); const USER_AGENT = ['Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36', '(KHTML, like Gecko) Chrome/48.0.2564.23 Mobile Safari/537.36'].join(' '); /** * Fetches the description from a webpage's metadata. * * @param {string} url of the page to get the description from * @return {Promise} with the description or error */ function fetchMetadataDescription(url) { return fetchWithUA(url) .then(response => response.text()) .then(html => cheerio.load(html)) .then($ => { return $('meta[name="description"]').attr('content'); }); } /** * Fetches the manifest URL from a webpage's link rel header. * * @param {string} url of the page to get the manifest link from * @return {Promise} with the URL or error */ function fetchLinkRelManifestUrl(pageUrl) { return fetchWithUA(pageUrl) .then(response => response.text()) .then(html => cheerio.load(html)) .then($ => $('link[rel="manifest"]').attr('href')) .then(newUrl => { if (!newUrl) { return Promise.reject( 'this Web does not have a Web App Manifest ()'); } return URL.resolve(pageUrl, newUrl); }); } /** * Fetches a URL using the USER_AGENT set on top of this file. * Uses spdy for http2 support * * @param {string} url to te be fetched * @return {Promise} */ function fetchWithUA(url) { const options = { method: 'GET', headers: { 'user-agent': USER_AGENT }, timeout: 1000 }; return fetch(url, options); } /** * Fetches a URL using the USER_AGENT set on top of this file and returns Json. * * @param {string} url to te be fetched * @return {Promise} */ function fetchJsonWithUA(url) { return fetchWithUA(url) .then(response => response.json()); } /** * Reads a file and returns a promise instead of the fs' callback. * * @param {string} filename to te be read * @return {Promise} with the content of the file */ function readFile(filename) { return new Promise((resolve, reject) => { fs.readFile(filename, {encoding: 'utf-8'}, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); } function _firebaseOptions(payload) { if (payload) { return { method: 'POST', headers: { 'Authorization': FIREBASE_AUTH, 'content-type': 'application/json' }, body: JSON.stringify(payload) }; } return { method: 'GET', headers: { Authorization: FIREBASE_AUTH } }; } function _handleFirebaseResponse(response) { // Request was successful. Resolve Promise with the JSON. if (response.status === 200) { return response.json(); } // Request returned an error response. Reject with an error message. return response.text() .then(text => { return Promise.reject( 'Request failed with response: ' + response.status + ' Message: ' + text); }); } function firebaseFetch(url, payload) { const options = _firebaseOptions(payload); const res = fetch(url, options); res.then(r => { if (r.status !== 200) { r.text().then(msg => { // Add codebase-wide logging system console.warn(`firebaseFetch error: GET ${url} => ${r.status}: ${msg}`); }); } }); return res.then(_handleFirebaseResponse); } /** * POST to url * * @param {string} url to POST to * * @param {string} body of the POST * @return {Promise} */ function postJson(url, body) { return fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); } module.exports = { fetchMetadataDescription, fetchLinkRelManifestUrl, fetchWithUA, fetchJsonWithUA, firebaseFetch, _firebaseOptions, _handleFirebaseResponse, readFile, postJson }; ================================================ FILE: lib/event-bus.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const EventEmitter = require('events'); const messageBus = new EventEmitter(); module.exports = messageBus; ================================================ FILE: lib/favorite-pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const db = require('../lib/model-datastore'); const FavoritePwa = require('../models/favorite-pwa'); const ENTITY_NAME = 'FavoritePwa'; /** * Saves a FavoritePwa object into the DB. * * @param {FavoritePwa} lighthouse * @return {Promise} */ exports.save = function(favoritePwa) { return db.update(ENTITY_NAME, favoritePwa.id, favoritePwa) .catch(err => { console.log(err); return Promise.reject('Error saving the FavoritePwa'); }); }; /** * Retrieves FavoritePwas for a given User. * * @param {number} userId * @return {Promise>} */ exports.findByUserId = function(userId) { console.log(userId); const query = db.createQuery(ENTITY_NAME).filter('userId', '=', userId); return db.runQuery(query).then(result => { if (!result || result.entities.length === 0) { return null; } let favoritePwas = result.entities.map(entry => { return new FavoritePwa(entry.pwaId, entry.userId); }); return favoritePwas; }); }; /** * Retrieves a FavoritePwa for given User & PWA. * * @param {number} pwaId * @param {number} userId * @return {Promise} */ exports.findFavoritePwa = function(pwaId, userId) { const query = db.createQuery(ENTITY_NAME).filter('pwaId', '=', parseInt(pwaId, 10)) .filter('userId', '=', userId).limit(1); return db.runQuery(query).then(result => { if (!result || result.entities.length === 0) { return null; } return new FavoritePwa(result.entities[0].pwaId, result.entities[0].userId); }); }; /** * Deletes a FavoritePwa from DB. * * @param {number} key of the FavoritePwa * @return {Promise<>} */ exports.delete = function(key) { return db.delete(ENTITY_NAME, key); }; ================================================ FILE: lib/images.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const dataFetcher = require('../lib/data-fetcher'); const sharp = require('sharp'); const stream = require('stream'); const strongDataUri = require('strong-data-uri'); const url = require('url'); const mime = require('mime-types'); const cloudStorage = require('@google-cloud/storage'); const CLOUD_BUCKET = config.get('CLOUD_BUCKET'); const storage = cloudStorage({ projectId: config.get('GCLOUD_PROJECT') }); const bucket = storage.bucket(CLOUD_BUCKET); const CACHE_CONTROL_EXPIRES = 60 * 60 * 24; // 1 day. /** * Fetches and Saves an Image to Google Cloud Storage. * * @param {string} url image URL to retrieve * @param {string} destFile name of the destination file * @return {Promise>} URLs for the new images in Google Cloud Storage */ function fetchAndSave(imageUrl, destFile) { const parsedUrl = url.parse(imageUrl); switch (parsedUrl.protocol) { case 'data:': { return this.dataUriAndSave(imageUrl); } case 'http:': case 'https:': { return dataFetcher.fetchWithUA(imageUrl) .then(response => { if (response.status !== 200) { return Promise.reject(new Error( 'Bad Response (' + response.status + ') loading image: ' + response.url)); } // Using mime.lookup insteand of `// response.headers.get('Content-Type');`, as some // publishers use an invalid value for the content-type. const contentType = mime.lookup(imageUrl); return this.saveImages(response.body, destFile, contentType); }) .then(savedUrls => { return savedUrls; }); } default: { return Promise.reject('Unsupported Protocol: ' + parsedUrl.protocol); } } } /** * Process a Data URI Image and Saves to Google Cloud Storage. * * @param {string} url Data URI image URL to process * @param {string} destFile name of the destination file * @return {Promise} URLs for the new images in Google Cloud Storage */ function dataUriAndSave(url, destFile) { const buffer = strongDataUri.decode(url); const contentType = buffer.mimetype; const bufferStream = new stream.PassThrough(); bufferStream.end(buffer); return this.saveImages(bufferStream, destFile, contentType); } /** * Saves the content from the stream to Google Cloud Storage * with 3 difference sizes, original, 128*128px and 64*64px * * @param {stream.Readable} stream * @param {string} destFile name of the destination file * @param {contentType} destFile image's mimetype * @return {Promise} URLs for the new images in Google Cloud Storage */ function saveImages(readStream, destFile, contentType) { return Promise.all([ this.saveImage(readStream, destFile, contentType), this.saveImage(readStream, destFile, contentType, 128), this.saveImage(readStream, destFile, contentType, 64) ]) .catch(err => { console.log('Error Saving Images', err); return null; }); } /** * Saves the content from the stream to Google Cloud Storage. * * @param {stream.Readable} stream * @param {string} destFile name of the destination file * @param {contentType} destFile image's mimetype * @param {int} size image's new size * @return {Promise} full public URL of saved image in Google Cloud Storage */ function saveImage(readStream, destFile, contentType, size) { const destFilename = (size || 'original') + '_' + destFile; return new Promise((resolve, reject) => { const file = bucket.file(destFilename); const metadata = { contentType: contentType, cacheControl: 'public, max-age=' + CACHE_CONTROL_EXPIRES }; const writeStream = file.createWriteStream({ metadata: metadata, gzip: 'auto' // Enables Gzipping the content based on the contentType. }); writeStream.on('error', err => { reject(err); }); writeStream.on('finish', () => { resolve(getPublicUrl(destFilename)); }); if (size) { const transformer = sharp().resize(size); transformer.on('error', err => { reject(err); }); readStream.pipe(transformer).pipe(writeStream); } else { readStream.pipe(writeStream); } }); } /** * @private * Given a filename, returns the GCloud address for it. * * @param {string} filename the original filename. * @return {Promise} the public URL of the file. */ function getPublicUrl(filename) { return 'https://storage.googleapis.com/' + CLOUD_BUCKET + '/' + filename; } module.exports = { fetchAndSave, saveImage, saveImages, dataUriAndSave }; ================================================ FILE: lib/lighthouse.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const pwaLib = require('../lib/pwa'); const libWebPerformance = require('../lib/web-performance'); const Lighthouse = require('../models/lighthouse'); const db = require('../lib/model-datastore'); const datastore = require('@google-cloud/datastore'); const ds = datastore({ projectId: config.get('GCLOUD_PROJECT') }); const ENTITY_NAME = 'Lighthouse'; const E_PWA_NOT_FOUND = exports.E_PWA_NOT_FOUND = 1; const E_FETCHING_STORING_LIGHTHOUSE = exports.E_FETCHING_STORING_LIGHTHOUSE = 2; const LIGTHOUSE_DATE_CHANGES = ['2016-12-01', '2017-03-01', '2017-05-05', '2017-05-25', '2017-06-20']; /** * Saves a Lighthouse object into the DB. * * @param {Lighthouse} lighthouse * @return {Promise} */ exports.save = function(lighthouse) { return new Promise((resolve, reject) => { db.update(ENTITY_NAME, lighthouse.id, lighthouse) .then(result => { return resolve(result); }) .catch(err => { console.log(err); return reject('Error saving the Lighthouse report'); }); }); }; /** * Retrieves the latest Lighthouse for a PWA. * * @param {number} pwaId * @return {Promise} */ exports.findByPwaId = function(pwaId) { return new Promise((resolve, reject) => { const query = ds.createQuery(ENTITY_NAME) .filter('pwaId', '=', parseInt(pwaId, 10)).order('date', {descending: true}).limit(1); ds.runQuery(query, (err, lighthouses) => { if (err) { return reject(err); } if (lighthouses.length === 0) { return resolve(null); } return resolve(lighthouses[0]); }); }); }; /** * Retrieves the Lighthouse data for a PWA * * @param {number} pwaId * @return {Promise} */ exports.getLighthouseByPwaId = function(pwaId) { return new Promise((resolve, reject) => { // Gets the last 2 years of Ligthouses for a PWA const query = ds.createQuery(ENTITY_NAME) .filter('pwaId', '=', parseInt(pwaId, 10)).order('date', {descending: true}).limit(730); ds.runQuery(query, (err, lighthouses) => { if (err) { return reject(err); } return resolve(lighthouses); }); }); }; /** * Retrieves the Lighthouse Grpah data for a PWA * in Google Charts JSON format * * @param {number} pwaId * @return {Promise} */ exports.getLighthouseGraphByPwaId = function(pwaId) { // Gets the last 2 years of Ligthouses for a PWA return this.getLighthouseByPwaId(pwaId) .then(lighthouses => { if (lighthouses.length === 0) { return null; } // Graph data uses the Google Charts JSON format: // https://developers.google.com/chart/interactive/docs/reference#dataparam let data = {}; data.cols = [{label: 'Date', type: 'date'}, {label: 'Score', type: 'number'}, {label: 'LH change', type: 'number'}]; data.rows = []; lighthouses.forEach(lighthouse => { let lighthouseChange = null; const date = new Date(Date.parse(lighthouse.date)); // Add dots over the line to anotate lighthouse changes if (LIGTHOUSE_DATE_CHANGES.indexOf(lighthouse.date) > -1) { lighthouseChange = lighthouse.totalScore; } data.rows.push( {c: [{v: 'Date(' + date.getFullYear() + ',' + date.getMonth() + ',' + date.getDate() + ')'}, {v: lighthouse.totalScore}, {v: lighthouseChange}]}); }); return data; }); }; /** * Generates a Lighthouse report for a PWA by its id. * * @param {number} pwaId * @return {Promise} */ exports.fetchAndSave = function(pwaId) { return new Promise((resolve, reject) => { pwaLib.find(pwaId) .then(pwa => { libWebPerformance.getLighthouseReport(pwa) .then(lighthouseJson => { const reportData = lighthouseJson[0].rawData.value; const lighthouse = new Lighthouse(pwaId, pwa.absoluteStartUrl, reportData); this.save(lighthouse); return lighthouse; }) .then(lighthouse => { return resolve(lighthouse); }) .catch(err => { console.error(err); return reject(E_FETCHING_STORING_LIGHTHOUSE); }); }) .catch(err => { console.error(err); return reject(E_PWA_NOT_FOUND); }); }); }; /** * Creates a new JSON with the main elemnts from a Lighthouse report. * * @param {string} lighthouseJson * @return {JSON} */ exports.processLighthouseJson = function(lighthouseJson) { let lighthouseInfo = {}; lighthouseInfo.lighthouseVersion = lighthouseJson.lighthouseVersion; // Some PWAs do not have a LH 2.x report yet if (lighthouseInfo.lighthouseVersion.startsWith('1.')) { lighthouseJson.aggregations.forEach(aggregation => { let i = 0; let totalScore = 0; if (aggregation.name === 'Progressive Web App') { let scoreJson = []; aggregation.score.forEach(score => { scoreJson[i++] = { name: score.name, overall: Math.round(score.overall * 100), subItems: JSON.stringify(score.subItems) }; totalScore += score.overall; }); lighthouseInfo.aggregation = { name: aggregation.name, description: aggregation.description, scores: scoreJson }; if (i > 0) { lighthouseInfo.totalScore = Math.round((totalScore / i) * 100); } } }); let j = 0; lighthouseInfo.audits = []; for (let key in lighthouseJson.audits) { if (lighthouseJson.audits.hasOwnProperty(key)) { let audit = lighthouseJson.audits[key]; lighthouseInfo.audits[j++] = { name: audit.name, description: audit.description, score: audit.score, displayValue: audit.displayValue, optimalValue: audit.optimalValue }; } } } else { lighthouseInfo.totalScore = Math.round(lighthouseJson.score); lighthouseInfo.reportCategories = lighthouseJson.reportCategories; } return lighthouseInfo; }; ================================================ FILE: lib/manifest.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const dataFetcher = require('../lib/data-fetcher'); const Manifest = require('../models/manifest'); /** * Fetches the Manifest from the manifestUrl. * * @param {string} manifestUrl * @return {Promise} */ function fetchManifest(manifestUrl) { return dataFetcher.fetchJsonWithUA(manifestUrl) .then(json => new Manifest(manifestUrl, json)); } /** * Wrapper for the manifest validator from lighthouse. * * @param {Manifest} manifest * @param {string} manifestUrl URL of manifest itself * @param {string} documentUrl URL of document that links to the manifest * @return string[] errors found in manifest */ function validateManifest(manifest, manifestUrl, documentUrl) { const parse = require('../third_party/manifest-parser.js'); const res = parse(manifest, manifestUrl, documentUrl); // Lighthouse annotates the actual elements with validation errors; "flatten" // these here. function flatten(obj) { const debugString = obj.debugString ? [obj.debugString] : []; if (typeof obj.value !== 'object') { return debugString; } return Object.keys(obj.value).reduce((acc, k) => { return acc.concat(flatten(obj.value[k])); }, debugString); } return flatten(res); } module.exports = { fetchManifest, validateManifest }; ================================================ FILE: lib/metadata.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; /** * Generates the default metadata from a http request */ module.exports.fromRequest = function(req, newUrl) { const host = req.get('host'); const url = newUrl || req.protocol + '://' + host + req.originalUrl; const timestamp = new Date().toISOString(); const logo = req.protocol + '://' + host + '/favicons/android-chrome-512x512.png'; const leader = req.protocol + '://' + host + '/img/pwa-directory-preview.png'; const metadata = { url: url, host: host, datePublished: timestamp, dateModified: timestamp, logo: logo, logoWidth: '512', logoHeight: '512', leader: leader, leaderWidth: '2008', leaderHeight: '1386' }; return metadata; }; ================================================ FILE: lib/model-datastore.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const datastore = require('@google-cloud/datastore'); const ds = datastore({ projectId: config.get('GCLOUD_PROJECT') }); const ENTITY_COUNT_KIND = 'counts'; /** * Translates from Datastore's entity format to * the format expected by the application. * * Datastore format: * { * key: [kind, id], * data: { * property: value * } * } * * Application format: * { * id: id, * property: value * } * * @param {Object} obj * @return {Object} */ function fromDatastore(obj) { obj.id = obj[datastore.KEY].id; return obj; } /** * Translates from the application's format to the datastore's * extended entity property format. It also handles marking any * specified properties as non-indexed. Does not translate the key. * * Application format: * { * id: id, * property: value, * unindexedProperty: value * } * * Datastore extended format: * [ * { * name: property, * value: value * }, * { * name: unindexedProperty, * value: value, * excludeFromIndexes: true * } * ] * * @param {Object} obj * @param {Array} nonIndexed * @return {Array} */ function toDatastore(obj, nonIndexed) { nonIndexed = nonIndexed || []; const results = []; Object.keys(obj).forEach(k => { if (obj[k] === undefined) { return; } let value; if (obj[k] instanceof Object) { if (nonIndexed.indexOf(k) === -1) { value = deepCopy(obj[k]); } else { // nonIndexed properties need to be stored as Strings value = JSON.stringify(obj[k]); } } else { value = obj[k]; } results.push({ name: k, value: value, excludeFromIndexes: nonIndexed.indexOf(k) !== -1 }); }); return results; } function deepCopy(object) { if (!(object instanceof Object)) { return object; } if (object instanceof Date) { return object; } const clone = Object.assign({}, object); Object.keys(clone).forEach(k => { clone[k] = deepCopy(clone[k]); }); return clone; } /** * Lists all Entities in the Datastore sorted alphabetically by title. * The ``limit`` argument determines the maximum amount of results to * return per page. The ``token`` argument allows requesting additional * pages. The callback is invoked with ``(err, Entities, nextPageToken)``. * * @param {string} kind * @param {number} offset * @param {number} limit * @param {object} {field:property name, config:sort direction} * @return {Promise>} */ function list(kind, offset, limit, sort, filters) { return runQuery(createQuery(kind, offset, limit, sort, filters)); } /** * Creates a DB query. * * @param {string} kind * @param {number} offset * @param {number} limit * @param {object} {field:property name, config:sort direction} * @return {query} */ function createQuery(kind, offset, limit, sort, filters) { const query = ds.createQuery([kind]) .offset(offset || 0) .limit(limit); if (sort) { query.order(sort.field, sort.config); } if (filters) { for (let filter of filters) { query.filter(filter.property, filter.operator, filter.value); } } return query; } /** * Executes a DB query. * * @param {query} query * @return {Promise>} */ function runQuery(query) { return new Promise((resolve, reject) => { ds.runQuery(query, (err, entities, nextQuery) => { if (err) { return reject(err); } const hasMore = nextQuery.moreResults !== 'NO_MORE_RESULTS'; resolve({ entities: entities.map(fromDatastore), hasMore: hasMore, endCursor: nextQuery.endCursor }); }); }); } /** * Parse the Key to a number if possible * @param {object} key * @return {object : number} */ function parseKey(key) { return isNaN(key) ? key : parseInt(key, 10); } function startTransaction(transaction) { return new Promise((resolve, reject) => { transaction.run(err => { if (err) { return reject(err); } return resolve(transaction); }); }); } function commitTransaction(transaction) { return new Promise((resolve, reject) => { transaction.commit(err => { if (err) { return reject(err); } return resolve(); }); }); } function rollbackTransaction(transaction) { return new Promise((resolve, reject) => { transaction.rollback(err => { if (err) { return reject(err); } return resolve(); }); }); } function transactionGet(transaction, key) { return new Promise((resolve, reject) => { transaction.get(key, (err, entity) => { if (err) { return reject(err); } return resolve(entity); }); }); } function updateCount(transaction, kind, inc) { return new Promise((resolve, reject) => { const countKey = ds.key([ENTITY_COUNT_KIND, kind]); transaction.get(countKey, (err, countEntity) => { if (err) { return reject(err); } let count = 0; if (countEntity) { count = countEntity.count; } if (inc) { count++; } else { count--; } transaction.save({key: countKey, data: {count: count}}); return resolve(); }); }); } /** * Creates a new Entity or updates an existing Entity with new data. The provided * data is automatically translated into Datastore format. The Entity will be * queued for background processing. * * @param {string} kind * @param {string} id * @param {Object} data * @return {Promise} */ function update(kind, id, data) { return new Promise((resolve, reject) => { let key; if (id) { key = ds.key([kind, parseKey(id)]); } else { key = ds.key(kind); } const entity = { key: key, data: toDatastore(data, [ 'description', '_manifest', '_lighthouseJson', 'lighthouseInfo', 'webPageTest']) }; ds.save( entity, err => { data.id = entity.key.id; if (err) { reject(err); return; } resolve(data); } ); }); } /** * Creates a new Entity or updates an existing Entity with new data. The provided * data is automatically translated into Datastore format. The Entity will be * queued for background processing. * * @param {string} kind * @param {string} id * @param {Object} data * @return {Promise} */ function updateWithCounts(kind, id, data) { let key; if (id) { key = ds.key([kind, parseKey(id)]); } else { key = ds.key(kind); } const entity = { key: key, data: toDatastore(data, [ 'description', '_manifest', '_lighthouseJson', 'lighthouseInfo', 'webPageTest']) }; const transaction = ds.transaction(); return startTransaction(transaction) .then(_ => { transaction.save(entity); if (!id) { return updateCount(transaction, kind, true); } return Promise.resolve(); }) .then(_ => { return commitTransaction(transaction); }) .then(_ => { data.id = key.id; return data; }) .catch(err => { console.error(err); return rollbackTransaction(transaction) .then(_ => { return Promise.reject(err); }); }); } function count(kind) { return read(ENTITY_COUNT_KIND, kind) .then(entity => { if (!entity) { return 0; } return entity.count || 0; }) .catch(_ => { return 0; }); } /** * Reads an Object of the specified kind and Id from the Datastore. * * @param {string} kind * @param {string} id * @return {Promise} */ function read(kind, id) { return new Promise((resolve, reject) => { const key = ds.key([kind, parseKey(id)]); ds.get(key, (err, entity) => { if (err) { return reject(err); } if (!entity) { return reject({ code: 404, message: 'Not found' }); } resolve(fromDatastore(entity)); }); }); } /** * Deletes an Object with the specified kind and Id from the Datastore * * @param {string} kind * @param {string} id * @return {Promise<>} */ function _deleteWithCounts(kind, id) { const key = ds.key([kind, parseKey(id)]); const transaction = ds.transaction(); return startTransaction(transaction) .then(_ => { return transactionGet(transaction, key); }) .then(entity => { if (!entity) { return Promise.reject('Trying to delete entity that does not exist: ' + key.id); } transaction.delete(key); return updateCount(transaction, kind, false); }) .then(_ => { return commitTransaction(transaction); }) .catch(err => { return rollbackTransaction(transaction) .then(_ => { return Promise.reject(err); }); }); } /** * Deletes an Object with the specified kind and Id from the Datastore * * @param {string} kind * @param {string} id * @return {Promise<>} */ function _delete(kind, id) { return new Promise((resolve, reject) => { const key = ds.key([kind, parseKey(id)]); ds.delete(key, err => { if (err) { return reject(err); } resolve(); }); }); } module.exports = { create: (kind, data) => { update(kind, null, data); }, count: count, read: read, update: update, delete: _delete, updateWithCounts: updateWithCounts, deleteWithCounts: _deleteWithCounts, list: list, createQuery: createQuery, runQuery: runQuery }; ================================================ FILE: lib/notifications.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const dataFetcher = require('../lib/data-fetcher'); exports.list = function(token) { if (!token) { return Promise.reject(new Error('Missing token')); } const url = 'https://iid.googleapis.com/iid/info/' + token + '?details=true'; return dataFetcher.firebaseFetch(url) .then(userDetails => { if (!userDetails || !userDetails.rel || !userDetails.rel.topics) { return Promise.resolve([]); } return Object.keys(userDetails.rel.topics); }); }; exports.subscribe = function(token, topic) { if (!token) { return Promise.reject(new Error('Missing token')); } if (!topic) { return Promise.reject(new Error('Missing topic')); } const url = 'https://iid.googleapis.com/iid/v1:batchAdd'; const payload = { to: '/topics/' + topic, registration_tokens: [token] // eslint-disable-line camelcase }; return dataFetcher.firebaseFetch(url, payload); }; exports.unsubscribe = function(token, topic) { if (!token) { return Promise.reject(new Error('Missing token')); } if (!topic) { return Promise.reject(new Error('Missing topic')); } const url = 'https://iid.googleapis.com/iid/v1:batchRemove'; const payload = { to: '/topics/' + topic, registration_tokens: [token] // eslint-disable-line camelcase }; return dataFetcher.firebaseFetch(url, payload); }; exports.sendPush = function(topic, notification) { if (!topic) { return Promise.reject(new Error('Missing topic')); } if (!notification) { return Promise.reject(new Error('Missing notification')); } // Require the notification to have a title, at minimum if (!notification.title) { return Promise.reject(new Error('Missing notification title')); } const url = 'https://fcm.googleapis.com/fcm/send'; const payload = { to: '/topics/' + topic, notification: notification }; return dataFetcher.firebaseFetch(url, payload); }; ================================================ FILE: lib/promise-sequential.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; /** Execute a list of Promise return functions serially * @param {list} a list of promise returning functions to execute serially * @return {Promise} the result of the last promise in the list * Example: * promiseSequential.all([ * _ => this.function1(result), * result => this.function2(result), * result => this.function3(result) * ]); */ exports.all = function(promiseList) { return promiseList.reduce((promiseFn, fn) => { return promiseFn.then(fn); }, Promise.resolve()); }; ================================================ FILE: lib/pwa-index.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const libSearch = require('../lib/search'); const libPwa = require('../lib/pwa'); const Pwa = require('../models/pwa'); const db = require('../lib/model-datastore'); const ENTITY_NAME = 'PWA'; /** * Add all PWAs from the DB into the text search index. */ exports.indexAllPwas = _ => { let indexPage = (skip, limit) => db.list(ENTITY_NAME, skip, limit) .then(result => { const pwas = result.entities.map(pwa => { return Object.assign(new Pwa(), pwa); }); libSearch.addPwas(pwas); if (result.hasMore) { return indexPage(skip + limit, limit); } console.log('All PWAs indexed'); return Promise.resolve(); }); return indexPage(0, 100); }; /** * Search for PWAS using the text search index. * * @param {string} query * @return {resultPage} resultPage with an arrays of PWAs and hasMore boolean */ exports.searchPwas = string => { return libSearch.search(string).then(result => { let pwas = new Array(result.length); let find = (currentValue, index) => { return libPwa.find(currentValue.ref).then(pwa => { // Inserting at index to keep result order // because Promises run in parallel pwas[index] = pwa; }); }; return Promise.all(result.map(find)).then(_ => { // Returning all results without pagination for now const resultPage = { pwas: pwas, hasMore: false }; return Promise.resolve(resultPage); }); }); }; /** * Update PWA in the search index. * * @param {Pwa} pwa to update * @return {Promise} */ exports.updateSearchIndex = function(pwa) { if (pwa.isNew()) { libSearch.addPwa(pwa); } else { libSearch.updatePwa(pwa); } return Promise.resolve(pwa); }; ================================================ FILE: lib/pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const path = require('path'); const url = require('url'); const config = require('../config/config'); const libImages = require('../lib/images'); const dataFetcher = require('../lib/data-fetcher'); const libWebPerformance = require('../lib/web-performance'); const libManifest = require('../lib/manifest'); const notificationsLib = require('../lib/notifications'); const promiseSequential = require('../lib/promise-sequential'); const libPwa = require('./pwa'); const libPwaIndex = require('./pwa-index'); const libCache = require('../lib/data-cache'); const Pwa = require('../models/pwa'); const db = require('../lib/model-datastore'); const datastore = require('@google-cloud/datastore'); const ds = datastore({ projectId: config.get('GCLOUD_PROJECT') }); const DEFAULT_SORT_TYPE_KEY = 'score'; const ENTITY_NAME = 'PWA'; const E_MANIFEST_INVALID_URL = exports.E_MANIFEST_INVALID_URL = 2; const E_MANIFEST_URL_MISSING = exports.E_MANIFEST_URL_MISSING = 3; const E_MISSING_USER_INFORMATION = exports.E_MISSING_USER_INFORMATION = 4; const E_NOT_A_PWA = exports.E_NOT_A_PWA = 5; // Waiting time to fetch external info and send notification for new PWAs const WAIT_TIME_NEW_PWAS = 10 * 60 * 1000; // 10 minutes const SORT_TYPE_MAP = new Map([ ['name', {name: 'name', field: 'manifest.name'}], ['newest', {name: 'newest', field: 'created', config: {descending: true}}], ['score', {name: 'score', field: 'lighthouseScore', config: {descending: true}}] ]); /** * List of PWAs. * * @param {number} skip specifies the starting point for handling pagination * @param {number} limit number of results to return * @param {string} sort the field name to sort the results * @return {resultPage} resultPage with an arrays of PWAs and hasMore boolean */ exports.list = async (skip, limit, sort, filters) => { filters = filters || {}; let sortType = SORT_TYPE_MAP.get(DEFAULT_SORT_TYPE_KEY); if (sort) { sortType = SORT_TYPE_MAP.get(sort) || sortType; } const queryFilters = []; if (filters.minLighthouseScore) { queryFilters.push({ property: 'lighthouseScore', operator: '>=', value: filters.minLighthouseScore }); } if (filters.installable) { queryFilters.push({ property: 'installable', operator: '=', value: filters.installable }); } const result = await db.list(ENTITY_NAME, skip, limit, sortType, queryFilters); const pwas = result.entities.map(pwa => { return Object.assign(new Pwa(), pwa); }); const resultPage = { pwas: pwas, hasMore: result.hasMore }; return resultPage; }; /** * Saves a PWA to DB. * * @param {Pwa} the Pwa to be saved. * @return a Promise. */ exports.savePwa = function(pwa) { return db.updateWithCounts(ENTITY_NAME, pwa.id, pwa) .then(savedPwa => { return savedPwa; }) .catch(err => { console.log('Error saving PWA err' + pwa.id); Promise.reject(err); }); }; /** * Finds a PWA by key. * * @param {number} key of the PWA * @return {Pwa} the PWA from DB */ exports.find = function(key) { return db.read(ENTITY_NAME, key) .then(pwa => { const pwaInstance = Object.assign(new Pwa(), pwa); return pwaInstance; }); }; /** * Finds a PWA by its manifest URL from DB. * * @param {string} manifestUrl of the PWA's manifest * @return {Pwa|null} the PWA from DB, or null if not found */ exports.findByManifestUrl = function(manifestUrl) { return new Promise((resolve, reject) => { const query = ds.createQuery(ENTITY_NAME).filter('manifestUrl', manifestUrl); ds.runQuery(query, (err, pwas) => { if (err) { return reject(err); } if (pwas.length === 0) { return resolve(null); } let pwa = Object.assign(new Pwa(), pwas[0]); return resolve(pwa); }); }); }; /** * Finds a PWA by its encodedStartUrl from DB. * * @param {string} encodedStartUrl of the PWA's manifest * @return {Pwa|null} the PWA from DB, or null if not found */ exports.findByEncodedStartUrl = function(encodedStartUrl) { return new Promise((resolve, reject) => { const query = ds.createQuery(ENTITY_NAME).filter('encodedStartUrl', encodedStartUrl); ds.runQuery(query, (err, pwas) => { if (err) { return reject(err); } if (pwas.length === 0) { return resolve(null); } let pwa = Object.assign(new Pwa(), pwas[0]); return resolve(pwa); }); }); }; /** * Creates or Updates a PWA. * * Steps: * 1) Validate Pwa * 2) Update Pwa's Manifest * 3) Save (to get the DB id for following steps) * 4) Update PWA's MetadataDescription * 5) Update PWA's Icon * 6) Save * 7) (in background): * a) Submit PWA for WebPerformance info * b) Get Pwa Performance info * c) Delete modified PWAs from cache * * @param {Pwa} pwa to update * @return {Pwa} the updated PWA */ exports.createOrUpdatePwa = function(pwa) { return promiseSequential.all([ _ => (pwa), this.validatePwa, this.updatePwaManifest, this.savePwa, this.updatePwaIcon, this.savePwa, this.removePwaFromCache, savedPwa => { // In background libPwaIndex.updateSearchIndex(savedPwa); this.submitWebPageUrlForWebPerformanceInformation(savedPwa); this.getPwaPerformanceInfo(savedPwa); return savedPwa; } ]); }; /** * Get Pwa Performance info * * @param {Pwa} pwa to update * @return {Promise} */ exports.getPwaPerformanceInfo = function(pwa) { let timeout = pwa.isNew() ? WAIT_TIME_NEW_PWAS : 0; setTimeout(_ => { return promiseSequential.all([ _ => (pwa), this.updatePwaMetadataDescription, this.updatePwaLighthouseInfo, this.updatePwaPageSpeedInformation, this.updatePwaWebPageTestInformation, this.savePwa, this.removePwaFromCache, this.sendNewAppNotification ]); }, timeout); }; /** * Remove PWA from cache * * @param {Pwa} pwa to remove * @return {Promise} */ exports.removePwaFromCache = function(pwa) { if (pwa.isNew()) { libCache.flushCacheUrls(); } // Delete modified PWA from cache const url = '/pwas/' + pwa.id; libCache.del(url) .catch(err => { console.error(`Error removing ${url} from memcached`, err.message); }); libCache.del(url + '?contentOnly=true') .catch(err => { console.error(`Error removing ${url} from memcached`, err.message); }); return Promise.resolve(pwa); }; /** * Validates PWA's data * * @param {Pwa} pwa to validate * @return {Promise} Promise with validated PWA or rejects with error */ exports.validatePwa = function(pwa) { if (!pwa || !(pwa instanceof Pwa)) { return Promise.reject(E_NOT_A_PWA); } if (!pwa.manifestUrl) { return Promise.reject(E_MANIFEST_URL_MISSING); } const manifestUrl = url.format(pwa.manifestUrl); if (!(manifestUrl.startsWith('http://') || manifestUrl.startsWith('https://'))) { return Promise.reject(E_MANIFEST_INVALID_URL); } if (!pwa.user || !pwa.user.id) { return Promise.reject(E_MISSING_USER_INFORMATION); } return Promise.resolve(pwa); }; /** * Fetches the manifest for a PWA using it's manifest URL * or the webpage's link rel=manifest * * @param {Pwa} the PWA to update * @return {Promise} with the manifest for the PWA */ exports.fetchManifest = function(pwa) { return libManifest.fetchManifest(pwa.manifestUrl) .then(manifest => { return manifest; }) .catch(_ => { // if there is not a manifest in the pwa.manifestUrl // we check if it is a webpage with a link rel=manifest to the manifest return dataFetcher.fetchLinkRelManifestUrl(pwa.manifestUrl) .then(newManifestUrl => { // remove hash from url pwa.manifestUrl = newManifestUrl.replace(/#.*/, ''); return libManifest.fetchManifest(newManifestUrl); }) .catch(err => { console.log('Error while fetching the PWA manifest ' + err); return Promise.reject(err); }); }); }; /** * Update PWA's Manifest. * * @param {Pwa} Pwa to update * @return {Pwa} the updated PWA */ exports.updatePwaManifest = function(pwa) { return libPwa.fetchManifest(pwa) .then(manifest => { return libPwa.findByManifestUrl(pwa.manifestUrl) .then(existingPwa => { if (existingPwa) { pwa = existingPwa; pwa.updated = new Date(); } const validationErrors = libPwa.validateManifest(pwa, manifest); if (validationErrors.length > 0) { return Promise.reject('Error while validating the manifest: ' + validationErrors); } pwa.manifest = manifest; // Creates a encodedStartUrl for human readable URLs pwa.generateEncodedStartUrl(); return pwa; }); }); }; /** * Validate PWA's Manifest. * * @param {Pwa} Pwa to validate * @param {Manifest} Manifest to validate * @returns {errors[]} Return errors in an array */ exports.validateManifest = function(pwa, manifest) { return libManifest.validateManifest( manifest.raw, pwa.manifestUrl, pwa.absoluteStartUrl); }; /** * Sends a push notification for new PWAs using Firebase Cloud Messaging. * * @param {Pwa} pwa to send the notification for * @return {Promise} with the notified PWA */ exports.sendNewAppNotification = function(pwa) { if (!pwa.isNew()) { return Promise.resolve(pwa); } console.log('Sending Notification for ', pwa.id); const clickAction = config.get('CANONICAL_ROOT') + 'pwas/' + pwa.id + '?utm_source=push'; return notificationsLib.sendPush('new-apps', { title: pwa.name + ' added to PWA Directory', body: pwa.description || '', icon: pwa.iconUrl64 || '', click_action: clickAction // eslint-disable-line camelcase }) .then(_ => { return pwa; }) .catch(err => { console.log('Error while sending PWA Notification ' + err); return pwa; }); }; /** * Deletes a PWA from DB. * * @param {number} key of the PWA * @return {Promise<>} */ exports.delete = function(key) { return db.delete(ENTITY_NAME, key); }; /** * Updates the description from the webpage's metadata if not present in the Manifest. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.updatePwaMetadataDescription = function(pwa) { return dataFetcher.fetchMetadataDescription(pwa.absoluteStartUrl) .then(metaDescription => { pwa.metaDescription = metaDescription; console.log('Updated PWA MetadataDescription: ', pwa.id); return pwa; }) .catch(err => { console.log('Error while updating PWA MetadataDescription ' + err); return pwa; }); }; /** * Updates the main icon of a PWA. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.updatePwaIcon = function(pwa) { const bestIconUrl = pwa.manifest.getBestIconUrl(); if (!bestIconUrl) { console.log('bestIconUrl is null'); return Promise.resolve(pwa); } const extension = path.extname(url.parse(bestIconUrl).pathname); const bucketFileName = pwa.id + extension; return libImages.fetchAndSave(bestIconUrl, bucketFileName) .then(savedUrls => { pwa.iconUrl = savedUrls[0]; pwa.iconUrl128 = savedUrls[1]; pwa.iconUrl64 = savedUrls[2]; console.log('Updated PWA Icon/Image: ', pwa.id); return pwa; }) .catch(err => { console.error('Error while updating PWA Icon/Image ' + err); return pwa; }); }; /** * Submit WebPageUrl to WebPerformance service, * that service runs daily WebPageTest, PageSpeed and Lighthouse. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.submitWebPageUrlForWebPerformanceInformation = function(pwa) { return libWebPerformance.submitWebPageUrl(pwa) .then(result => { if (result.status === 200) { console.log('Submited PWA for WebPerformance info: ', pwa.id); } else { console.log('Error while submiting PWA for WebPerformance information: ' + pwa.id + ' ' + JSON.stringify(result)); } return pwa; }) .catch(err => { console.log( 'Error while submiting PWA for WebPerformance information: ' + pwa.id + ' ' + err); return pwa; }); }; /** * Updates the Lighthouse information. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.updatePwaLighthouseInfo = function(pwa) { return libWebPerformance.getLighthouseReport(pwa) .then(lighthouseJson => { // We are not using the full rawData or rawDataBlob report anymore delete lighthouseJson[0].rawData; delete lighthouseJson[0].rawDataBlob; pwa.lighthouseScore = Math.round(lighthouseJson[0].pwaScore); pwa.lighthouse = lighthouseJson[0]; pwa.installable = lighthouseJson[0].installable; console.log('Updated PWA Lighthouse info for: ', pwa.id); return pwa; }) .catch(err => { console.log('Error while updating PWA Lighthouse information ' + err); return pwa; }); }; /** * Update PageSpeed information. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.updatePwaPageSpeedInformation = function(pwa) { return libWebPerformance.getPageSpeedReport(pwa) .then(pageSpeedJson => { console.log('Updated PWA PageSpeed info: ', pwa.id); pwa.pageSpeed = pageSpeedJson[0]; return pwa; }) .catch(err => { console.log('Error while updating PageSpeed information: ' + pwa.id + ' ' + err); return pwa; }); }; /** * Update WebPageTest information. * * @param {Pwa} the PWA to update * @return {Promise} with the updated PWA */ exports.updatePwaWebPageTestInformation = function(pwa) { return libWebPerformance.getWebPageTestReport(pwa) .then(json => { console.log('Updated PWA WebPageTest info: ', pwa.id); let webPageTestJson = json[0]; // remove rawFirstViewData to make the field smaller than 1500 bytes webPageTestJson.rawFirstViewData = null; pwa.webPageTest = webPageTestJson; return pwa; }) .catch(err => { console.log('Error while updating WebPageTest information: ' + pwa.id + ' ' + err); return pwa; }); }; exports.count = function() { return db.count(ENTITY_NAME); }; ================================================ FILE: lib/search.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const URL = require('url'); const elasticlunr = require('elasticlunr'); const libCache = require('../lib/data-cache'); const libPwaIndex = require('../lib/pwa-index'); const CACHE_LIFETIME = 60 * 60 * 24 * 7; // 7 days const SEARCH_INDEX_CHANGE = 'SearchIndexChange'; /** * Search class for the elasticlunr functions * * Exports a singleton object instance */ class Search { constructor() { this._initIndex(); } _initIndex() { this._index = elasticlunr(function() { this.setRef('id'); this.addField('displayName'); this.addField('urlText'); }); this._modified = new Date(); } /** * Create a doc element from a PWA. * * @param {PWA} PWA to index * @return {doc} a doc for the text search engine */ _docFromPwa(pwa) { const url = URL.parse(pwa.absoluteStartUrl); const urlText = url.hostname.replace(/\.|-/g, ' '); return { id: pwa.id, displayName: pwa.displayName, urlText: urlText }; } /** * Add a PWA to the search index. * * @param {PWA} PWA to index * @return {Promise} the doc added to the text search engine */ addPwa(pwa) { const doc = this._addPwa(pwa); this.sarchIndexChange(); return Promise.resolve(doc); } /** * Add a list of PWA to the search index. * * @param {PWA[]} PWAs to index * @return {Promise<>} */ addPwas(pwas) { pwas.forEach(pwa => this._addPwa(pwa)); this.sarchIndexChange(); return Promise.resolve(); } _addPwa(pwa) { const doc = this._docFromPwa(pwa); this._index.addDoc(doc); return doc; } /** * Update a PWA on the search index. * * @param {PWA} PWA to update * @return {Promise} the doc updated on the text search engine */ updatePwa(pwa) { const doc = this._docFromPwa(pwa); this._index.updateDoc(doc); this.sarchIndexChange(); return Promise.resolve(doc); } /** * Remove a PWA from the search index. * * @param {PWA} PWA to remove * @return {Promise} the doc removed from the text search engine */ removePwa(pwa) { const doc = this._docFromPwa(pwa); this._index.removeDoc(doc); this.sarchIndexChange(); return Promise.resolve(doc); } /** * Search the text index. * * @param {string} query * @return {Promise} with the matching PWA Ids and scores * * [{ * "ref": 123456789, * "score": 0.5376053707962494 * }, * { * "ref": 456789012, * "score": 0.5237481076838757 * }] */ search(string) { const options = {expand: true}; const result = this._index.search(string, options); // Update the search index for the next query this.checkForSearchIndexChange(); return Promise.resolve(result); } /** * Record a change in the search index in memcached. * * @param {date} optional date */ sarchIndexChange(date = new Date()) { libCache.set(SEARCH_INDEX_CHANGE, date, CACHE_LIFETIME) .catch(err => console.error('sarchIndexChange error', err.message)); } /** * Check of the latest change in the search index and updete if needed. * * @param {date} optional date */ checkForSearchIndexChange() { libCache.get(SEARCH_INDEX_CHANGE).then(lastChange => { lastChange = new Date(lastChange); if (lastChange && lastChange > this._modified) { console.log('Re-index PWAs'); // Invalidate index and re-index all PWAs this._initIndex(); libPwaIndex.indexAllPwas().then(_ => { const newDate = new Date(); this._modified = newDate; this.sarchIndexChange(newDate); }); } }); } } // Export Search as a singleton object module.exports = new Search(); ================================================ FILE: lib/tasks.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const db = require('../lib/model-datastore'); const Task = require('../models/task'); const pwaLib = require('../lib/pwa'); const tasksLib = require('../lib/tasks'); const ENTITY_NAME = 'Task'; const E_SAVING_TASK = exports.E_SAVING_TASK = 1; const E_GET_TASK_POP = exports.E_GET_TASK_POP = 2; const E_DELETE_TASK_POP = exports.E_DELETE_TASK_POP = 3; /** * Push a Task object into the DB. * * @param {Task} lighthouse * @return {Promise} */ exports.push = function(task) { return new Promise((resolve, reject) => { db.update(ENTITY_NAME, task.id, task) .then(result => { return resolve(result); }) .catch(err => { console.error(err); return reject(E_SAVING_TASK); }); }); }; exports.getTasks = async function(numTasks) { const result = await db.list(ENTITY_NAME, 0, numTasks, {field: 'created', config: {ascending: true}}); const tasks = []; for (let entity of result.entities) { tasks.push(Object.assign(new Task(), entity)); } return tasks; }; exports.deleteTask = async function(taskId) { await db.delete(ENTITY_NAME, taskId); }; /** * Pop the oldest Task * * @return {Promise} */ exports.pop = function() { return new Promise((resolve, reject) => { db.list(ENTITY_NAME, 0, 1, {field: 'created', config: {ascending: true}}) .then(result => { if (result.entities.length === 0) { return resolve(null); } let task = Object.assign(new Task(), result.entities[0]); db.delete(ENTITY_NAME, task.id) .then(_ => { return resolve(task); }).catch(err => { console.error('Error deleting task', err); return reject(E_DELETE_TASK_POP); }); }) .catch(_ => { return reject(E_GET_TASK_POP); }); }); }; /** * Execute a task * * @return {Promise} */ exports.executePwaTask = function(task) { if (!task) { return Promise.resolve(); } return pwaLib.find(task.pwaId) .then(pwa => { // Dynamically get module and function to execute from task with a PWA // const moduleFromTask = require(task.modulePath); const moduleFromTask = require('../lib/pwa'); const functionFromTask = Reflect.get(moduleFromTask, task.functionName); return functionFromTask.call(moduleFromTask, pwa) .then(_ => { return task; }) .catch(err => { console.error('Error running task: ' + err); task.retries -= 1; if (task.retries >= 0) { tasksLib.push(task); } return task; }); }) .catch(err => { console.error(err); }); }; /** * Pop the oldest Task and execute it * * @return {Promise} */ exports.popExecute = function() { return tasksLib.pop() .then(task => { if (task) { return tasksLib.executePwaTask(task); } return null; }); }; ================================================ FILE: lib/verify-id-token.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const CLIENT_ID = config.get('CLIENT_ID'); const CLIENT_SECRET = config.get('CLIENT_SECRET'); /** * @param {string} idToken * @return {Promise} */ exports.verifyIdToken = function(idToken) { const {OAuth2Client} = require('google-auth-library'); const client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET); return new Promise((resolve, reject) => { client.verifyIdToken({idToken, CLIENT_ID}, (err, googleLogin) => { if (err) { reject(err); } resolve(googleLogin); }); }); }; ================================================ FILE: lib/web-performance.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const config = require('../config/config'); const dataFetcher = require('../lib/data-fetcher'); const WEBPERFORMANCE_SERVER_URL = config.get('WEBPERFORMANCE_SERVER'); const WEBPERFORMANCE_SERVER_API_KEY = config.get('WEBPERFORMANCE_SERVER_API_KEY'); const WEBPERFORMANCE_SERVER_WEBPAGEURL = WEBPERFORMANCE_SERVER_URL + 'webpageurl'; const WEBPERFORMANCE_SERVER_PAGESPEED_REPORT = WEBPERFORMANCE_SERVER_URL + 'pagespeedreport/'; const WEBPERFORMANCE_SERVER_WEBPAGETEST_REPORT = WEBPERFORMANCE_SERVER_URL + 'webpagetestreport/'; const WEBPERFORMANCE_SERVER_LIGHTHOUSE_REPORT = WEBPERFORMANCE_SERVER_URL + 'lighthousereport/'; function submitToWebPerformanceService(pwa) { const body = { id: pwa.id, url: pwa.absoluteStartUrl, source: 'pwa-directory', description: pwa.description, created: pwa.created }; return dataFetcher.postJson( WEBPERFORMANCE_SERVER_WEBPAGEURL + '?key=' + WEBPERFORMANCE_SERVER_API_KEY, body); } /** * Submit PWA to the WebPerformance service. * * @param {number} a PWA * @return {Promise} */ exports.submitWebPageUrl = function(pwa) { return new Promise((resolve, reject) => { submitToWebPerformanceService(pwa) .then(result => { return resolve(result); }) .catch(err => { return reject(err); }); }); }; /** * Get Report for PWA. * * @param {PWA} a PWA * @return {Promise} */ function getReport(url) { return dataFetcher.fetchWithUA(url) .then(response => { if (response.status === 200) { return response.json(); } else if (response.status === 404) { return Promise.reject('not available yet'); } return Promise.reject(response); }) .catch(err => { return Promise.reject(err); }); } /** * Get PageSpeed Report for PWA. * * @param {PWA} a PWA * @return {Promise} */ exports.getPageSpeedReport = function(pwa) { return getReport(WEBPERFORMANCE_SERVER_PAGESPEED_REPORT + pwa.id + '?limit=1'); }; /** * Get WebPageTest Report for PWA. * * @param {PWA} a PWA * @return {Promise} */ exports.getWebPageTestReport = function(pwa) { return getReport(WEBPERFORMANCE_SERVER_WEBPAGETEST_REPORT + pwa.id + '?limit=1'); }; /** * Get Lighthouse Report for PWA. * * @param {PWA} a PWA * @return {Promise} */ exports.getLighthouseReport = function(pwa) { return getReport(WEBPERFORMANCE_SERVER_LIGHTHOUSE_REPORT + pwa.id + '?limit=1'); }; ================================================ FILE: lighthouse_machine/.dockerignore ================================================ .gitignore .git outputs ================================================ FILE: lighthouse_machine/.eslintrc.json ================================================ { "extends": "google", "installedESLint": true, // http://eslint.org/docs/rules/ "rules": { "max-len": [2, 100, { "ignoreComments": true, "ignoreUrls": true, "tabWidth": 2 }], "no-implicit-coercion": [2, { "boolean": false, "number": true, "string": true }], "no-unused-expressions": [2, { "allowShortCircuit": true, "allowTernary": false }], "no-unused-vars": [2, { "vars": "all", "args": "after-used", "argsIgnorePattern": "(^reject$|^_$)", "varsIgnorePattern": "(^_$)" }], "quotes": [2, "single"], "require-jsdoc": 0, "valid-jsdoc": 0, "prefer-arrow-callback": 1, "no-var": 1 }, // http://eslint.org/docs/user-guide/configuring#specifying-environments "env": { "node": true } } ================================================ FILE: lighthouse_machine/.gitignore ================================================ outputs node_modules ================================================ FILE: lighthouse_machine/Dockerfile ================================================ # Copyright 2016-2017, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM ubuntu:latest ## PART 1: Core components ## ======================= # Install utilities RUN apt-get update --fix-missing && apt-get -y upgrade &&\ apt-get install -y sudo apt-utils curl wget unzip git gnupg # Install node 10 RUN curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - &&\ sudo apt-get install -y nodejs # Install Xvfb and dbus for X11 RUN apt-get install -y xvfb dbus-x11 # Install Chrome for Ubuntu RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - &&\ sudo sh -c 'echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' &&\ sudo apt-get update &&\ sudo apt-get install -y google-chrome-stable # Install Yarn RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - &&\ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list &&\ sudo apt-get update && sudo apt-get install yarn # Copy key documents (except .dockerignored files) COPY etc/xvfb /etc/init.d/xvfb RUN chmod +x /etc/init.d/xvfb # Add a user and make it a sudo user RUN useradd -m chromeuser # Copy the chrome-user script used to start Chrome as non-root COPY chromeuser-script.sh / RUN chmod +x /chromeuser-script.sh ## PART 2: Lighthouse ## ================== # Download lighthouse RUN git clone https://github.com/googlechrome/lighthouse &&\ cd /lighthouse &&\ git checkout tags/v4.2.0 &&\ npm install -g yarn &&\ npm install -g yarnpkg &&\ npm install -g @types/mkdirp &&\ npm install -g --save-dev run-sequence &&\ npm install -g typescript &&\ npm install -g &&\ yarn global add lighthouse ## PART 3: Express server ## ====================== # Install express COPY package.json / RUN npm install # Add the simple server file COPY server.js / RUN chmod +x /server.js # Add the cpu monitor file COPY cpu_monitor.js / RUN chmod +x /cpu_monitor.js # Generate a self-signed SSL certificate RUN openssl req \ -new \ -newkey rsa:4096 \ -days 365 \ -nodes \ -x509 \ -subj "/C=GB/ST=None/L=None/O=Google/CN=lighthouse-machine-X" \ -keyout key.pem \ -out cert.pem # Expose ports 8080 and 8443 EXPOSE 8080 EXPOSE 8443 ## PART 4: Final setup ## =================== # Set the entrypoint COPY entrypoint.sh / RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: lighthouse_machine/LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the 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. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communiampion sent to the Licensor or its representatives, including but not limited to communiampion on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communiampion that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2015, Google Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: lighthouse_machine/README.md ================================================ # Lighthouse machine A Docker image to run [Lighthouse](https://github.com/GoogleChrome/lighthouse) scores on a server ## Build the image ```bash docker build --no-cache -t lighthouse_machine . ``` ## Run the container ```bash # Run a new container docker run -d -p 8080:8080 --cap-add=SYS_ADMIN lighthouse_machine ``` ## Usage ```bash curl -X GET 'http://localhost:8080?format=${format}&url=${url}' ``` where `format`is one of `json`, `html` (see [cli-options](https://github.com/GoogleChrome/lighthouse#cli-options) for more information) ## License See [LICENSE](./LICENSE) for more. ## Disclaimer This is not a Google product. ================================================ FILE: lighthouse_machine/app.yaml ================================================ # Copyright 2016-2017, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. runtime: custom env: flex service: lighthouse-machine automatic_scaling: min_num_instances: 2 max_num_instances: 6 cool_down_period_sec: 60 cpu_utilization: target_utilization: 0.6 resources: cpu: 1 memory_gb: 4 disk_size_gb: 10 handlers: - url: /.* script: IGNORED secure: always liveness_check: path: '/_ah/health' check_interval_sec: 30 timeout_sec: 4 failure_threshold: 3 success_threshold: 2 initial_delay_sec: 60 readiness_check: path: '/_ah/busy' check_interval_sec: 3 timeout_sec: 2 failure_threshold: 1 success_threshold: 1 app_start_timeout_sec: 300 network: instance_tag: lighthouse-machine ================================================ FILE: lighthouse_machine/chromeuser-script.sh ================================================ #!/bin/bash # Copyright 2016-2017, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. sudo chown -R chromeuser:chromeuser $TMP_PROFILE_DIR export DISPLAY=:0 Xvfb :0 -screen 0 1024x768x24 & nohup google-chrome --no-first-run --disable-gpu --no-sandbox --user-data-dir=$TMP_PROFILE_DIR --remote-debugging-port=9222 'about:blank' & ================================================ FILE: lighthouse_machine/cpu_monitor.js ================================================ /** * Copyright 2016-2017, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const os = require('os'); // Create function to get CPU information function cpuAverage() { // Initialise sum of idle and time of cores and fetch CPU info let totalIdle = 0; let totalTick = 0; let cpus = os.cpus(); // Loop through CPU cores for (let i = 0, len = cpus.length; i < len; i++) { // Select CPU core let cpu = cpus[i]; // Total up the time in the cores tick // eslint-disable-next-line guard-for-in for (let type in cpu.times) { totalTick += cpu.times[type]; } // Total up the idle time of the core totalIdle += cpu.times.idle; } // Return the average Idle and Tick times return {idle: totalIdle / cpus.length, total: totalTick / cpus.length}; } module.exports = (avgTime, callback) => { this.samples = []; this.samples[1] = cpuAverage(); this.refresh = setInterval(() => { this.samples[0] = this.samples[1]; this.samples[1] = cpuAverage(); let totalDiff = this.samples[1].total - this.samples[0].total; let idleDiff = this.samples[1].idle - this.samples[0].idle; callback(1 - idleDiff / totalDiff); }, avgTime); }; ================================================ FILE: lighthouse_machine/entrypoint.sh ================================================ #!/bin/bash # Copyright 2016-2017, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. /etc/init.d/dbus start /etc/init.d/xvfb start sleep 1s export DISPLAY=:1 TMP_PROFILE_DIR=$(mktemp -d -t lighthouse.XXXXXXXXXX) su chromeuser source /chromeuser-script.sh sleep 3s node /server.js ================================================ FILE: lighthouse_machine/etc/xvfb ================================================ #!/bin/bash # Copyright 2016-2017, Google, Inc. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. XVFB_OUTPUT=/tmp/Xvfb.out XVFB=/usr/bin/X11/Xvfb XVFB_OPTIONS=":1 -screen 0 1024x768x24 -fbdir /var/run" start() { echo -n "Starting : X Virtual Frame Buffer " $XVFB $XVFB_OPTIONS >>$XVFB_OUTPUT 2>&1& RETVAL=$? echo return $RETVAL } stop() { echo -n "Shutting down : X Virtual Frame Buffer" echo pkill Xvfb echo return 0 } case "$1" in start) start ;; stop) stop ;; status) status xvfb ;; restart) stop start ;; *) echo "Usage: xvfb {start|stop|status|restart}" exit 1 ;; esac exit $? ================================================ FILE: lighthouse_machine/package.json ================================================ { "name": "lighthouse_machine_server", "version": "1.0.0", "description": "A server for the lighthouse machine", "repository": "https://github.com/GoogleChrome/gulliver/lighthouse_machine", "author": "Google Inc.", "contributors": [ { "name": "Cedric Bellet", "email": "cbellet@google.com" }, { "name": "Julian Toledo", "email": "jtoledo@google.com" } ], "license": "Apache-2.0", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.16.3" } } ================================================ FILE: lighthouse_machine/server.js ================================================ /** * Copyright 2016-2017, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const express = require('express'); const exec = require('child_process').exec; const http = require('http'); const https = require('https'); const fs = require('fs'); const cpuMonitor = require('./cpu_monitor'); // Chrome panick let chromePanick = false; // CPU monitoring let cpuPoints = new Array(5); let cpuAlert = false; cpuMonitor(60000, load => { // Add new measurements to the cpuPoints array cpuPoints.pop(); cpuPoints.unshift(load); // Calculate the avg and spread of the cpuPoints array let sum = 0; let i = 5; while (i--) sum += cpuPoints[i]; let avg = sum / 5; let spread = Math.max.apply(Math, cpuPoints) - Math.min.apply(Math, cpuPoints); // If the CPU load is above 80% and the spread is less than 10%, trigger an alert cpuAlert = (avg > 0.8 && spread < 0.1); cpuAlert && console.log(`Average: ${avg}, Spread: ${spread}`); }); // Constants const HTTP_PORT = 8080; const HTTPS_PORT = 8443; // HTTPS options const options = { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') }; // App const app = express(); let isBusy = false; // Main endpoint app.get('/', (req, res) => { if (isBusy) { res.sendStatus(429); } else { isBusy = true; res.setTimeout(500000, _ => { console.log('Request has timed out.'); res.send(408); }); try { exec( `lighthouse '${req.query.url}' --port 9222 --output-path ../report.${req.query.format} --output ${req.query.format}`, { cwd: '/lighthouse', timeout: 500000 }, error => { if (error !== null) { console.log(`exec error: ${error}`); // This is for when Chrome crashes and Lighthouse is unable to reconnect // to an appropriate instance of Chrome if (error.message.includes('Unable to connect')) { chromePanick = true; } } isBusy = false; res.sendFile(`/report.${req.query.format}`); } ); } catch (e) { isBusy = false; res.status(500).send(e); } } }); // Auto-healing endpoint app.get('/_ah/health', (req, res) => { if (chromePanick) { // If we have a Chrome panick send a 500 res.sendStatus(500); } else if (cpuAlert) { // if we have a CPU alert send a 500, otherwise send a 200 res.sendStatus(500); } else { res.sendStatus(200); } }); // Busy-ness endpoit app.get('/_ah/busy', (req, res) => { if (isBusy) { res.sendStatus(503); } else { res.sendStatus(200); } }); http.createServer(app).listen(HTTP_PORT); https.createServer(options, app).listen(HTTPS_PORT); console.log( `Running on https://localhost:${HTTPS_PORT} and http://localhost:${HTTP_PORT}` ); ================================================ FILE: middlewares/index.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const express = require('express'); const asset = require('../lib/asset-hashing').asset; const router = express.Router(); // eslint-disable-line new-cap const CSSPATH = asset.encode('/css/style.css'); const JSPATH = asset.encode('/js/gulliver.js'); router.use((req, res, next) => { res.setHeader('Content-Type', 'text/html'); /* eslint-disable quotes */ res.setHeader('content-security-policy', [ `connect-src 'self' https://www.google-analytics.com https://web-performance-dot-pwa-directory.appspot.com https://fcm.googleapis.com`, `default-src 'self' https://accounts.google.com https://apis.google.com https://fcm.googleapis.com`, `script-src 'self' 'unsafe-eval' https://apis.google.com https://www.google-analytics.com https://www.gstatic.com`, `style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com/ajax/libs/font-awesome/ https://www.gstatic.com`, `font-src 'self' https://cdnjs.cloudflare.com/ajax/libs/font-awesome/`, `img-src 'self' https://storage.googleapis.com https://www.google-analytics.com` ].join('; ')); /* eslint-enable quotes */ res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('x-dns-prefetch-control', 'off'); res.setHeader('x-download-options', 'noopen'); res.setHeader('x-frame-options', 'SAMEORIGIN'); res.setHeader('x-xss-protection', '1; mode=block'); // Set the preload header if a full render is being requested. if (!req.query.contentOnly && !req.originalUrl.startsWith('/.app/')) { res.setHeader('Link', `<${CSSPATH}>; rel=preload; as=style, <${JSPATH}>; rel=preload; as=script`); } next(); }); module.exports = router; ================================================ FILE: models/favorite-pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; /** * Favorite Pwa for a user */ class FavoritePwa { constructor(pwaId, userId) { this.id = pwaId + '-' + userId; this.pwaId = pwaId; this.userId = userId; } } module.exports = FavoritePwa; ================================================ FILE: models/lighthouse.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const libLighthouse = require('../lib/lighthouse'); /** * Class representing a Lighthouse report for a PWA * * absoluteStartUrl is the absoluteStartUrl of the PWA * lighthouseJson is the Lighthouse's report as JSON object */ class Lighthouse { constructor(pwaId, absoluteStartUrl, lighthouseJson) { this.pwaId = pwaId; this.absoluteStartUrl = absoluteStartUrl; this._lighthouseJson = parseToJson(lighthouseJson); this.lighthouseInfo = libLighthouse.processLighthouseJson(this._lighthouseJson); this.totalScore = this.lighthouseInfo.totalScore; this.lighthouseVersion = this.lighthouseInfo.lighthouseVersion; this.date = (new Date()).toISOString().slice(0, 10); this.id = this.pwaId + '-' + this.date; } get lighthouseJson() { return this._lighthouseJson; } set lighthouseJson(value) { // lighthouseJson is stored as a string in the datastore this._lighthouseJson = parseToJson(value); } } function parseToJson(value) { if (value && Object.prototype.toString.call(value) === '[object String]') { return JSON.parse(value); } return value; } module.exports = Lighthouse; ================================================ FILE: models/manifest.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const url = require('url'); /** * Class representing a Web App Manifest */ class Manifest { constructor(manifestUrl, jsonManifest) { this.url = manifestUrl; this.raw = JSON.stringify(jsonManifest); this.name = jsonManifest.name; this.shortName = jsonManifest.short_name; this.description = jsonManifest.description; this.startUrl = jsonManifest.start_url; this.backgroundColor = jsonManifest.background_color; this.icons = jsonManifest.icons; this.scope = jsonManifest.scope; } getBestIcon() { function getIconSize(icon) { if (!icon.sizes) { return 0; } return parseInt(icon.sizes.substring(0, icon.sizes.indexOf('x')), 10); } if (!this.icons) { return null; } let bestIcon; let bestIconSize; for (let icon of this.icons) { if (!bestIcon) { bestIcon = icon; bestIconSize = getIconSize(icon); } const iconSize = getIconSize(icon); if (iconSize > bestIconSize) { bestIcon = icon; bestIconSize = iconSize; } // We can return 128 and 144 even if there are bigger ones. if (iconSize === 128 || iconSize === 144) { return icon; } } return bestIcon; } /** Gets the Url for the largest icon in the Manifest */ getBestIconUrl() { let bestIcon = this.getBestIcon(); if (!bestIcon || !bestIcon.src) { return ''; } return url.resolve(this.url, bestIcon.src); } } module.exports = Manifest; ================================================ FILE: models/pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const uri = require('urijs'); const URL = require('url'); const Manifest = require('../models/manifest'); const User = require('../models/user'); class Pwa { constructor(manifestUrl, manifestModel) { // remove hash from url manifestUrl && (this.manifestUrl = removeHash(manifestUrl)); this._manifest = stringifyManifestIfNeeded(manifestModel); this.created = new Date(); this.updated = this.created; this.visible = true; } get shortName() { if (!this.manifest) { return ''; } return this.manifest.shortName || ''; } get name() { if (!this.manifest) { return ''; } return this.manifest.name || ''; } get displayName() { return this.name || this.shortName || trimManifestFile(this.manifestUrl); } get description() { if (this.manifest && this.manifest.description) { return this.manifest.description; } return this.metaDescription || ''; } get startUrl() { if (!this.manifest) { return ''; } return this.manifest.startUrl || ''; } get absoluteStartUrl() { if (!this.manifestUrl) { return ''; } const startUrl = this.startUrl || '/'; return this._cleanUrl(uri(startUrl).absoluteTo(this.manifestUrl).toString()); } get backgroundColor() { if (!this.manifest) { return '#ffffff'; } return this.manifest.backgroundColor || '#ffffff'; } get manifest() { if (!this._manifest) { return null; } return new Manifest(this.manifestUrl, JSON.parse(this._manifest)); } set manifest(value) { if (value && typeof value === 'object') { this._manifest = value.raw; } else { this._manifest = value; } } get manifestAsString() { return this._manifest; } setUser(user) { this.user = new User(user); } generateEncodedStartUrl() { const parsedUrl = URL.parse(this.absoluteStartUrl); this.encodedStartUrl = encodeURIComponent(parsedUrl.hostname + parsedUrl.pathname); return this.encodedStartUrl; } isNew() { return this.created === this.updated; } _cleanUrl(input) { const url = new URL.URL(input); for (const name of url.searchParams.keys()) { if (name.toLowerCase().startsWith('utm_')) { url.searchParams.delete(name); } } return url.toString(); } } function trimManifestFile(url) { let startIndex = url.indexOf('//'); if (startIndex === -1) { startIndex = 0; } else { startIndex += 2; } let endIndex = url.lastIndexOf('/'); if (endIndex === -1) { endIndex = url.length; } return url.substring(startIndex, endIndex); } function stringifyManifestIfNeeded(manifest) { if (manifest && typeof manifest === 'object') { return manifest.raw; } return manifest; } function removeHash(urlString) { const url = URL.parse(urlString); url.hash = ''; return url.format(); } module.exports = Pwa; ================================================ FILE: models/task.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; class Task { constructor(pwaId, modulePath, functionName, retries) { this.pwaId = pwaId; this.modulePath = modulePath; this.functionName = functionName; this.retries = retries; this.created = new Date(); } } module.exports = Task; ================================================ FILE: models/user.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict'; const crypto = require('crypto'); /** * User from google-auth-library-nodejs client */ class User { constructor(googleLogin) { this.id = crypto.createHash('sha1').update(googleLogin.getPayload().sub).digest('hex'); } } module.exports = User; ================================================ FILE: package.json ================================================ { "name": "gulliver", "version": "1.0.0", "description": "A directory of PWAs", "repository": "https://github.com/GoogleChrome/gulliver", "private": true, "scripts": { "start": "node app.js", "prestart": "BABEL_ENV=default rollup -c rollup-config/gulliver.js && npm run generate-msg-sw", "monitor": "nodemon app.js", "deploy": "npm run prestart && gcloud app deploy app.yaml", "mocha-app": "_mocha test/app/**/* --exit", "mocha-client": "BABEL_ENV=test _mocha --compilers js:babel-core/register test/client/**/*.js", "coverage": "istanbul cover _mocha --compilers js:babel-core/register test/app/**/*", "lint": "eslint .", "test": "npm run lint && npm run mocha-client && npm run mocha-app", "generate-msg-sw": "node firebase-messaging-sw-generator.js", "lint-fix": "eslint --fix ." }, "author": "Google Inc.", "contributors": [ { "name": "Julian Toledo", "email": "jtoledo@google.com" }, { "name": "Michael Stillwell", "email": "stillers@google.com" }, { "name": "Andre Bandarra", "email": "andreban@google.com" } ], "license": "Apache Version 2.0", "semistandard": { "globals": [ "after", "afterEach", "before", "beforeEach", "describe", "it" ] }, "engines": { "nodejs8": "8.12.0" }, "dependencies": { "@google-cloud/datastore": "^1.4.2", "@google-cloud/storage": "^1.7.0", "babel-preset-es2015-rollup": "^3.0.0", "body-parser": "^1.18.3", "cheerio": "^0.22.0", "compression": "^1.7.3", "elasticlunr": "^0.9.5", "escape-html": "^1.0.3", "express": "^4.16.4", "express-csv": "^0.6.0", "express-minify-html": "^0.12.0", "express-sslify": "^1.2.0", "firebase": "^5.5.9", "google-auth-library": "^1.6.1", "handlebars": "^4.5.3", "hbs": "^4.0.4", "http-parser-js": "^0.4.13", "jsdom": "^9.5.0", "lodash.merge": "^4.6.2", "lodash.template": "^4.5.0", "memcached": "^2.2.2", "mime-types": "^2.1.21", "moment": "^2.22.2", "multer": "^1.4.1", "nconf": "^0.8.4", "node-fetch": "^2.6.1", "parse-color": "^1.0.0", "request": "^2.88.0", "rev-hash": "^1.0.0", "rollup": "^0.58.2", "rollup-plugin-babel": "^2.6.1", "rollup-plugin-commonjs": "^9.2.0", "rollup-plugin-node-resolve": "^2.0.0", "rollup-plugin-uglify": "^1.0.1", "rss": "^1.2.2", "serve-static": "^1.11.1", "sharp": "^0.17.0", "spdy": "^3.4.7", "strong-data-uri": "^1.0.6", "sw-offline-google-analytics": "^1.1.1", "sw-toolbox": "^3.2.1", "urijs": "^1.18.1", "url-polyfill": "^1.1.0", "whatwg-fetch": "^2.0.1", "yaku": "^0.17.6" }, "devDependencies": { "babel-preset-es2015": "^6.24.1", "chai": "^3.0.0", "chai-as-promised": "^6.0.0", "eslint": "^6.6.0", "eslint-config-google": "^0.6.0", "istanbul": "^0.4.4", "mocha": "^5.2.0", "node-mocks-http": "^1.7.3", "simple-mock": "^0.7.0", "supertest": "^3.3.0" } } ================================================ FILE: public/.well-known/assetlinks.json ================================================ [{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.appspot.pwa_directory", "sha256_cert_fingerprints": ["1A:64:23:29:C2:BB:FA:18:45:A3:BE:02:08:DD:B4:8F:51:21:F9:2E:95:75:75:CA:2B:8B:47:75:94:C5:0F:64"]} }] ================================================ FILE: public/css/style.css ================================================ /* Copyright 2015-2016, Google, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ html { height: 100%; box-sizing: border-box; overflow-x: hidden; } *, *:before, *:after { box-sizing: inherit; /* don't show Chrome's default blue tap highlight */ -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } body { font-size: 16px; font-weight: 300; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; width: 100%; min-width: 310px; margin: 0; background: #F5F5F5; position: relative; min-height: 100%; padding-bottom: 10px; overflow-x: hidden; } pre, code { font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif; font-size: 14px; } h3 { font-size: 22px; font-weight: 400; letter-spacing: -.018em; text-overflow: ellipsis; } a { text-decoration: none; color: inherit; } a:hover { text-decoration: underline; } main { padding-top: 16px; padding-right: 8px; padding-left: 8px; padding-bottom: 44px; transition: opacity 0.3s ease-in-out; width: 100vw; } /* Navbar */ .navbar { position: relative; height: 82px; display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; } .navbar-title { height: 55px; color: white; font-size: 30px; text-align: center; line-height: 55px; } .navbar-title #title { -ms-flex-item-align: start; align-self: flex-start; margin: 0 auto; text-align: left; padding-left: 55px; background-position: left center; background-repeat: no-repeat; background-image: url(/favicons/android-chrome-48x48.png); background-image: -webkit-image-set( url(/favicons/android-chrome-48x48.png) 1x, url(/favicons/android-chrome-96x96.png) 2x ); } .navbar-subtitle { height: 20px; color: white; font-size: 18px; text-align: center; line-height: 20px; } .navbar a { color: white; text-decoration: none; } .section { color: white; font-size: 16px; font-weight: 500; } .section-top { height: 48px; line-height: 48px; display: flex; display: -webkit-flex; } .section-tabs { height: 48px; line-height: 48px; display: flex; display: -webkit-flex; } .section-top #subtitle { margin: 0 16px; } .back { height: 48px; float: left; } .back svg { fill: white; } .back:hover { opacity: 0.5; cursor: pointer; } .back:active { background-color: #90CAF9; } .section-tabs .tab:active, .section-tabs .back:active { background-color: #90CAF9; } .section-tabs .tab { height: 48px; line-height: 48px; margin: 0; opacity: 0.7; font-weight: 500; text-transform: uppercase; flex-grow: 1; text-align: center; } .section-tabs .activetab { color: white; border-bottom-style: solid; opacity: 1; } .section-tabs a, .section-tabs a:visited { color: white; text-decoration: none; } .section-tabs.tab:hover { border-bottom-style: solid; background: #90CAF9; border-color: #FF8A65; border-width: 2dp; opacity: 1; } @media (min-width: 904px) { .section { height: 48px; } .section-top { max-width: 50%; min-width: 50%; float: right; } .section-tabs { position: relative; max-width: 50%; } } /* Flex NavBar */ @media (max-width: 607px) { .navbar { height: 55px; padding-left: 8px; } .navbar-title { font-size: 24px; text-align: left; } .navbar-title #title { padding-left: 40px; background-image: url(/favicons/android-chrome-36x36.png); background-image: -webkit-image-set( url(/favicons/android-chrome-36x36.png) 1x, url(/favicons/android-chrome-72x72.png) 2x ); } .navbar-subtitle { height: 0px; } .navbar .notifications { bottom: 16px; } .hide-on-mobile { display: none; } } .button-primary { border: 1px solid transparent; border-radius: 2px; box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); max-width: 100px; } .button { background: none; font-size: 16px; font-weight: 500; color: white; min-width: 120px; height: 30px; text-align: center; display: inline-block; -ms-touch-action: manipulation; touch-action: manipulation; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; background-image: none; border: none; text-transform: uppercase; margin: 0; transition: opacity 0.2s ease-in-out; } button:active, button[disabled=disabled], button:disabled { opacity: 0.5; cursor: default; } .button:not([disabled]):hover { opacity: 0.5; } .button a { color: white; text-decoration: none; display: block; width: 100%; height: 100%; } .box-shadow { box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); } /* PWA List Box */ .card-pwa { text-overflow: hidden; border-radius: 2px; padding: 8px; display: flex; align-items: center; height: 132px; flex-direction: column; transition: opacity .2s ease-in-out; pointer-events: auto; } a.card-pwa:hover { text-decoration: none; } .card-pwa .pwa-name { padding-top: 8px; font-size: 18px; font-weight: 600; height: 36px; text-align: center; letter-spacing: .005em; overflow: hidden; text-overflow: ellipsis; width: 100%; white-space: nowrap; } .pwa-icon { width: 64px; height: 64px; text-align: center; font-size: 64px; } .score { float: right; font-weight: 600; background: no-repeat center left url(/img/lighthouse-18.png); background: no-repeat center left -webkit-image-set( url(/img/lighthouse-18.png) 1x, url(/img/lighthouse-36.png) 2x ); padding-left: 24px; } .detail-general { max-width: 600px; -webkit-display: flex; display: -webkit-box; display: -ms-flexbox; display: flex; -ms-flex-flow: column; flex-flow: column; -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between; margin: 0 auto 16px auto; padding: 0; text-overflow: hidden; font-weight: 400; box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); background: white; border-radius: 2px; } .detail-general h3 { color: #333333; margin: 0; padding: 16px; } .detail-general a { color: #1976D2; text-decoration: underline; } .transition .detail-general { opacity: 0; } /* Lighthouse details */ .lighthouse-details { padding-bottom: 0.5em; text-align: center; font-size: 18px; } .viewer-placeholder-logo { vertical-align:middle; padding-right: 8px; } /* PWA Overview */ #pwa { padding: 16px; display: flex; flex-direction: column; align-items: center; transition: all 0.3s cubic-bezier(.25,.8,.25,1); } #pwa:hover { text-decoration: none; } body:not(.transition) #pwa:hover { box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); } #pwa #pwa-logo-container { height: 128px; width: 128px; } #pwa:active { opacity: 0.5; box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); } #pwa code { display: block; text-align: center; text-decoration: underline; margin-top: 24px; min-height: 18px; word-break: break-all; } #pwa > div { margin-top: 16px; min-height: 18px; width: 100%; text-align: center; } #pwa .dates { font-style: italic; font-size: 12px; line-height: 16px; } /* Lighthouse graph */ .chart { width: 100%; min-height: 276px; padding: 16px; border-top-style: solid; border-width: 1px; border-color: #ccc; position: relative; } .chart #chart-missing { position: absolute; text-align: center; top: 45%; width: 100%; } .chart #chart-missing.fadeIn { display: block; } #chart_AnnotationChart_borderDiv { height: 244px !important; border-style: none; animation: 0.25s ease-in fadeIn; animation-fill-mode: forwards; } /* Lighthouse report table */ table { margin: 0; font-size: 14px; width: 100%; padding: 0 16px; border-spacing: 0; border-collapse: collapse; border-style: hidden; } th { color: #1976D2; height: 64px; line-height: 64px; font-weight: 500; text-transform: uppercase; } th:nth-child(1), td:nth-child(1) { text-align: left; padding-left: 16px; } th:nth-child(2), td:nth-child(2) { width: 60px; text-align: right; padding-right: 16px; } tr { height: 48px; color: #333; border-width: 1px; border-color: #eee; border-top-style: solid; } tr:last-child td { height: 56px; padding-bottom: 8px; } /* Add PWA */ .form-group { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; margin: 8px; height: 120px; justify-content: space-between; font-size: 16px; font-weight: 600; } .form-input { height: 34px; font-size: 16px; font-weight: 600; border-radius: 2px; background: #FFFFFF; border: 0px solid lightgray; outline: none; } .error { margin-left: 8px; color: #f07; } /* Serch PWA */ .search-form { flex-grow: 1; } .search-form .form-input { margin-left: auto; padding-left: 8px; padding-right: 34px; } .search-group { position: relative; display: -webkit-box; display: -ms-flexbox; display: flex; align-items: center; height: 48px; line-height: 48px; padding-left: 8px; } .search-group input { flex-grow: 1; } #search-icon { min-width: 0; position: absolute; right: 8px; height: 48px; } #search-icon svg { height: 48px; width: 28px; fill: #757575; } .toolbar-icon svg { height: 44px; width: 34px; fill: #FFFFFF; } .toolbar-button { min-width: 0; position: relative; height: 48px; } .toolbar-button:active { background: #90CAF9; opacity: 1; } .toolbar-button:focus { outline:0; } #newest { margin-left: auto; } #score { margin-right: auto; } /* Footer */ .offline-status { position: fixed; bottom: 0; width: 100%; background-color: #dc322f; color: #ffffff; height: 56px; line-height: 56px; padding-left: 20px; pointer-events: none; transform: translateY(56px); opacity: 0; transition-property: transform,opacity; transition-duration: .5s; transition-timing-function: ease-in-out; } /* Footer */ footer { position: absolute; width:100%; bottom: 0; display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; color: white; } footer ul { display: -webkit-box; display: -ms-flexbox; display: flex; -ms-flex-flow: row; flex-flow: row; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; padding: 0; } footer ul li:first-child { border-left: none; } footer li { display: block; padding: 0 1em; border-left: 1px solid #fff; line-height: 24px; height: 24px; } footer ul li a { -ms-flex-flow: row wrap; flex-flow: row wrap; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; text-align: center; text-decoration: none; font-weight: 400; color: #fff; padding: 0; cursor: pointer; } footer #github-logo { vertical-align: top; width: 24px; height: 24px; } .page-holder { position: relative; display: flex; } .page-loader { position: absolute; width: 100%; top: 120px; background: #F5F5F5; transition: opacity 0.3s ease-in-out; z-index: -1; } /* Pager */ .pager { height: 48px; width: 200px; margin: 16px auto; } .pager svg { fill: #1976D2; } .previous, .next { transition: opacity 0.2s ease-in-out; } .previous { float: left; } .next { float: right; } /* Controls Offline and SignedIn Behavirous */ main.transition, body[offline] .offline-aware:not([cached]), body:not([signedIn]) .signin-aware { opacity: 0.3; pointer-events: none; box-shadow: none; } body[offline] .offline-status { transform: translateY(0); opacity: 1; } body:not([signedIn]) .auth-button-label-logout, body[signedIn] .auth-button-label-login { display: none; } .pager a { color: #0D47A1; text-decoration: none; } /* Flex List of PWAs */ .items { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-flex-wrap: wrap; flex-wrap: wrap; } .transition .items { opacity: 0; } .item a { text-decoration: none; display: block; width: 100%; height: 100%; } .items .item { -webkit-box-flex: 1; -ms-flex: 1 0 280px; flex: 1 0 280px; background: white; color: #171e42; margin: 8px; max-width: calc(100% - 16px); } /* Fixes last flex row min-widt: 280*colums+16*(colums+1) */ @media (min-width: 608px) { .items .item { max-width: calc(50% - 16px); } } @media (min-width: 904px) { .items .item { max-width: calc(33.33333% - 16px); } } @media (min-width: 1200px) { .items .item { max-width: calc(25% - 16px); } } @media (min-width: 1496px) { .items .item { max-width: calc(20% - 16px); } } @media (min-width: 1792px) { .items .item { max-width: calc(16.66667% - 16px); } } @media (min-width: 2088px) { .items .item { max-width: calc(14.2857142857% - 16px); } } @media (min-width: 2384px) { .items .item { max-width: calc(14.2857142857% - 16px); } } /* Styles for highlighted JSON */ pre { tab-size: 2; padding: 16px; margin: 0; overflow-x: auto; } .string { color: #0288D1; } .number { color: red; } .boolean { color: #FF5252; } .null { color: magenta; } .key { color: #212121; } .toolbar { width: 100%; display: flex; margin: 0 8px; } .toolbar > div { width: 50%; } .pwa-count { font-weight: 600; height: 50px; line-height: 22px; text-align: end; padding-right: 5px; } /* Notifications */ .notifications { position: absolute; font-size: 16px; bottom: 24px; right: 10px; } .notifications svg { position: absolute; top: -6px; right: 48px; fill: #FFFFFF; } .switch-label { position: relative; display: block; height: 20px; width: 44px; background: #898989; border-radius: 100px; cursor: pointer; transition: all 0.3s ease; } .switch-label:after { position: absolute; left: -2px; top: -3px; display: block; width: 26px; height: 26px; border-radius: 100px; background: #fff; content: ''; transition: all 0.3s ease; } .switch-label:active:after { transform: scale(1.15, 0.85); } .switch:checked ~ label:after { left: 20px; background: #FF5722; } .switch:checked ~ label { background: #FFCCBC; } .switch:disabled ~ label { background: #d5d5d5; pointer-events: none; } .switch:disabled ~ label:after { background: #bcbdbc; } #notifications_active { visibility: hidden; } .switch:checked ~ #notifications_active { visibility: visible; } .switch:checked ~ #notifications_off { visibility: hidden; } .hidden { display: none; } .fadeIn { animation: 0.25s ease-in fadeIn; animation-fill-mode: forwards; } @keyframes fadeIn { 0% { opacity: 0 } 100% { opacity: 1 } } .fadeOut { animation: 0.25s ease-out fadeOut; animation-fill-mode: forwards; } @keyframes fadeOut { 0% { opacity: 1 } 100% { opacity: 0 } } /* Loader */ .loader { text-align: center; display: block; height: 10px; white-space: nowrap; z-index: 1; } .loader-dot { position: relative; display: inline-block; height: 10px; width: 10px; margin: 2px; border-radius: 100%; background-color: #000; opacity: 0.6; box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, .2); will-change: transform; animation: loader-dots 2s infinite; } .loader .loader-dot:nth-child(1) { animation-delay: 0s; } .loader .loader-dot:nth-child(2) { animation-delay: .1s; } .loader .loader-dot:nth-child(3) { animation-delay: .2s; } @keyframes loader-dots { 0%, 100% { transform: scale(.7); opacity: 0.5; } 50% { transform: scale(.8); opacity: 0.9; } } /* Theme */ .primary-color { color: #2196F3; } .primary-background { background: #2196F3; } .dark-primary-color { color: #1976D2; } .dark-primary-background { background: #1976D2; } .accent-color { color: #FF5722; } .activetab, .accent-border { border-color: #FF5722; } .button-primary, .accent-background { background: #FF5722; } .link-disabled { pointer-events: none; } ================================================ FILE: public/favicons/browserconfig.xml ================================================ #7cc0ff ================================================ FILE: public/google3915c2aaf77f961f.html ================================================ google-site-verification: google3915c2aaf77f961f.html ================================================ FILE: public/humans.txt ================================================ # humanstxt.org/ # The humans responsible & technology colophon # TEAM Julian Toledo -- @juliantoledo Michael Stillwell -- @ithinkihaveacat Andre Bandarra -- @andreban Alberto Medina -- @amedina # THANKS Ade Oshineye -- @ade_oshineye # TECHNOLOGY COLOPHON CSS3, HTML5, GoogleChrome sw-toolbox Node, Google App Engine, GoogleChrome Lighthouse Source: https://github.com/GoogleChrome/gulliver ================================================ FILE: public/js/analytics.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export default class Analytics { constructor(window, config) { this.navigator = window.navigator; this.window = window; this.config = config; this._init(); this._setupA2HTracking(); } _init() { // Setup Tracking if analytics is not loaded yet. if (!this.window.ga) { this.window.ga = (...args) => { (this.window.ga.q = this.window.ga.q || []).push(args); }; } this.window.ga('create', this.config.ga_id, 'auto'); this.window.ga('set', 'transport', 'beacon'); } /** * Setup a listener to track Add to Homescreen events. */ _setupA2HTracking() { this.window.addEventListener('beforeinstallprompt', e => { e.userChoice.then(choiceResult => { this.window.ga('send', 'event', 'A2H', choiceResult.outcome); }); }); } trackOutboundClick(url) { this.window.ga('send', 'event', 'outbound', 'click', url, {transport: 'beacon'}); } trackPageView(url) { this.window.ga('set', 'page', url); this.window.ga('set', 'dimension1', this.navigator.onLine); this.window.ga('send', 'pageview'); } } ================================================ FILE: public/js/chart.js ================================================ /** * copyright 2015-2016, google, inc. * licensed under the apache license, version 2.0 (the "license"); * you may not use this file except in compliance with the license. * you may obtain a copy of the license at * * http://www.apache.org/licenses/license-2.0 * * unless required by applicable law or agreed to in writing, software * distributed under the license is distributed on an "as is" basis, * without warranties or conditions of any kind, either express or implied. * see the license for the specific language governing permissions and * limitations under the license. */ /* global google */ /* eslint-env browser */ import Loader from './loader'; /** * Use to make the API request to get the Lighthouse chart data for a PWA. */ export default class Chart { constructor(config) { this.chartElement = config.chartElement; this.url = config.url; this.loader = new Loader(this.chartElement, 'dark-primary-background'); } _loadChartsApi() { return new Promise((resolve, reject) => { const chartScript = document.getElementById('google-chart'); if (chartScript) { if (window.google) { resolve(window.google); } else { chartScript.addEventListener('load', _ => resolve(window.google)); } } else { const script = document.createElement('script'); script.id = 'google-chart'; script.defer = true; script.src = 'https://www.gstatic.com/charts/loader.js'; script.onload = _ => resolve(window.google); script.onerror = reject; document.head.appendChild(script); } }); } load() { this.loader.show(); this._loadChartsApi().then(google => { google.charts.load('45.2', {packages: ['annotationchart']}); google.charts.setOnLoadCallback(this.drawChart.bind(this)); }); } drawChart() { if (!this.url) { return; } const pagewith = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); fetch(this.url) .then(response => response.json()) .then(jsonData => { // Create our data table out of JSON data loaded from server. const data = new google.visualization.DataTable(jsonData); if (data.getNumberOfRows() > 0) { const chart = new google.visualization.AnnotationChart(this.chartElement); const options = { height: 242, displayAnnotations: false, displayRangeSelector: false, displayZoomButtons: (pagewith > 420), legendPosition: 'newRow', thickness: 4, min: 0, max: 100 }; chart.draw(data, options); this.loader.hide(); } else { this.loader.hide(); const missingChart = this.chartElement.querySelector('div#chart-missing'); missingChart.classList.add('fadeIn'); } }) .catch(err => { this.loader.hide(); const missingChart = document.getElementById('chart-missing'); missingChart.classList.add('fadeIn'); console.error('There was an error drawing the chart!', err); }); } } ================================================ FILE: public/js/event-target.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export default class EventTarget { constructor() { this._listeners = new Map(); } addEventListener(type, callback) { let typeListeners = this._listeners.get(type); if (!typeListeners) { typeListeners = new Set(); this._listeners.set(type, typeListeners); } typeListeners.add(callback); } removeEventListener(type, callback) { const typeListeners = this._listeners.get(type); if (!typeListeners) { return; } typeListeners.delete(callback); } getEventListeners(type) { return this._listeners.get(type); } dispatchEvent(event) { if (!event.type) { return; } const typeListeners = this._listeners.get(event.type); if (!typeListeners) { return; } typeListeners.forEach(callback => callback(event)); } } ================================================ FILE: public/js/gapi.es6.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ /** * Returns a Promise that fulfills to `window.gapi`. Note that this function * will probably create the global properties `gapiReady` and `gapiResolve`. * * @param {typeof window} context * @param {typeof document} doc * @return {Promise} */ export function gapi(context = window, doc = document) { return context.gapiReady || new Promise(resolve => { // Adapted from GA embed code const c = 'gapiResolve'; const s = doc.createElement('script'); const p = doc.getElementsByTagName('script')[0]; s.async = 1; s.src = `https://apis.google.com/js/api.js?onload=${c}`; p.parentNode.insertBefore(s, p); context[c] = () => resolve(window.gapi); }); } /** * @template T * @param {string} name the library to load * @return {Promise} resolves to window.gapi[name] */ export function gapiLoad(name) { return gapi().then(g => { return new Promise(resolve => { g.load(name, () => resolve(g[name])); }); }); } /** * Promise'd version of [`gapi.client.load`](https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiclientloadname--------version--------callback). * * @template T * @param {string} name the API client to load * @param {string} [version="v1"] version * @return {Promise} resolves to gapi.client[name] */ export function clientLoad(name, version) { version = version ? version : 'v1'; return gapiLoad('client').then(client => { return new Promise(resolve => { client.load(name, version, () => resolve(client[name])); }); }); } /** * Promise'd version of [`gapi.auth2.init`](https://developers.google.com/identity/sign-in/web/reference#gapiauth2initparams). * * @param {any} params https://developers.google.com/identity/sign-in/web/reference#gapiauth2initparams * @return {Promise} Promise resolving to an initialized gapi.auth2.GoogleAuth object */ export function authInit(params) { return gapiLoad('auth2').then(auth2 => { /* Ideally we'd just return `auth2.init(params)` here, but * instead we need to work around a few bugs and surprises in * `auth2.init()` and the "Promise" it returns. */ return new Promise(resolve => { auth2.init(params).then(t => { t.then = null; resolve(t); }); }); }); } ================================================ FILE: public/js/gulliver-config.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export default class Config { constructor(element) { if (!element) { console.log('%cConfig not found', 'color:red'); return; } const configJSON = JSON.parse(element.innerHTML); Object.assign(this, configJSON); } static from(element) { return new Config(element); } } ================================================ FILE: public/js/gulliver.es6.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * Generate gulliver.js from this file via `npm prestart`. (`npm start` will run * `prestart` automatically.) */ /* eslint-env browser */ // A Promise polyfill, as used by // https://github.com/Financial-Times/polyfill-service/blob/master/polyfills/Promise/config.json import 'yaku/dist/yaku.browser.global.min.js'; // A fetch polyfill, as used by // https://github.com/Financial-Times/polyfill-service/blob/master/polyfills/fetch/config.json import 'whatwg-fetch/fetch'; import Messaging from './messaging'; import NotificationCheckbox from './ui/notification-checkbox'; import Config from './gulliver-config'; import SignIn from './signin'; import OfflineSupport from './offline-support'; import {ShareButton} from './ui/share-button'; import {SignInButton, SignOutButton} from './ui/signin-button'; import Analytics from './analytics'; import Router from './routing/router'; import Route from './routing/route'; import Shell from './shell'; import {LoaderTransitionStrategy} from './routing/transitions'; import PwaForm from './pwa-form'; import Chart from './chart'; import SearchInput from './search-input'; const CHART_BASE_URLS = { lighthouse: 'https://web-performance-dot-pwa-directory.appspot.com/lighthousereport/PWAID?graph=true', psi: 'https://web-performance-dot-pwa-directory.appspot.com/pagespeedreport/PWAID?graph=true', wpt: 'https://web-performance-dot-pwa-directory.appspot.com/webpagetestreport/PWAID?graph=true' }; class Gulliver { constructor() { this.config = Config.from(document.querySelector('#config')); this.shell = new Shell(document); this.router = new Router(window, document.querySelector('main')); this.offlineSupport = new OfflineSupport(window, this.router); SearchInput.setupSearchElements(this.router); // Setup share button this.shareButton = new ShareButton(window, document.querySelector('#share-button')); // Setup SignIn this.signIn = new SignIn(window, this.config); this.signInButton = new SignInButton(window, this.signIn, document.querySelector('#signin-button')); this.signOutButton = new SignOutButton(window, this.signIn, document.querySelector('#signout-button')); // Setup Analytics this.analytics = new Analytics(window, this.config); this.analytics.trackPageView(window.location.href); this.router.addEventListener('navigateoutbound', e => { this.analytics.trackOutboundClick(e.detail.url); }); this.router.addEventListener('navigate', e => { this.analytics.trackPageView(e.detail.url); this.shell.onRouteChange(e.detail.route); this.offlineSupport.markAsCached(document.querySelectorAll('.offline-aware')); }); this._setupRoutes(); this.setupBacklink(); this.setupServiceWorker(); this.setupMessaging(); } _addRoute(regexp, transitionStrategy, onRouteAttached, shellState) { const route = new Route(regexp, transitionStrategy, onRouteAttached); this.shell.setStateForRoute(route, shellState); this.router.addRoute(route); } _setupRoutes() { const transitionStrategy = new LoaderTransitionStrategy(window); // Route for `/pwas/add`. const setupPwaForm = () => { const pwaForm = new PwaForm(window, this.signIn); pwaForm.setup(); }; // Link search-input value to search query paramter const setupSearchInput = () => { const urlParams = new URLSearchParams(window.location.search); document.querySelector('#search-input').value = urlParams.get('query'); }; this._addRoute(/\/pwas\/add/, transitionStrategy, [setupPwaForm, setupSearchInput], { showTabs: false, backlink: true, subtitle: true, search: true }); const setupCharts = () => { const generateChartConfig = chartElement => { const pwaId = chartElement.getAttribute('pwa'); const type = chartElement.getAttribute('type'); const url = CHART_BASE_URLS[type].replace('PWAID', pwaId); return {chartElement: chartElement, url: url}; }; const charts = Array.from(document.getElementsByClassName('chart')); charts.forEach(chart => new Chart(generateChartConfig(chart)).load()); }; // Route for `/pwas/score`. this._addRoute(/\/pwas\/score/, transitionStrategy, setupSearchInput, { showTabs: true, backlink: false, subtitle: true, search: true, currentTab: 'score' }); // Route for `/pwas/newest`. this._addRoute(/\/pwas\/newest/, transitionStrategy, setupSearchInput, { showTabs: true, backlink: false, subtitle: true, search: true, currentTab: 'newest' }); // Route for `/pwas/[id]`. Allow most characters (but will only ever be encodedURIComponent). this._addRoute(/\/pwas\/.+/, transitionStrategy, [setupCharts, setupSearchInput], { showTabs: false, backlink: true, subtitle: true, search: true }); // Route for `/?search=`. this._addRoute(/\/pwas\/search\?query/, transitionStrategy, setupSearchInput, { showTabs: false, backlink: true, subtitle: true, search: true }); // Route for `/`. this._addRoute(/.+/, transitionStrategy, setupSearchInput, { showTabs: true, backlink: false, subtitle: true, search: true, currentTab: 'installable' }); this.router.setupInitialRoute(); } /** * Register service worker. */ setupServiceWorker() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(r => { console.log('REGISTRATION', r); }); } else { console.log('SW not registered; navigator.serviceWorker is not available'); } } /** * Setup/configure Firebase Cloud Messaging. */ setupMessaging() { try { const NEW_APPS_TOPIC = 'new-apps'; const firebaseMsgSenderId = this.config.firebase_msg_sender_id; const checkbox = document.getElementById('notifications'); const messaging = new Messaging(firebaseMsgSenderId); // eslint-disable-next-line no-unused-vars const notificationCheckbox = new NotificationCheckbox(messaging, checkbox, NEW_APPS_TOPIC); } catch (e) { console.log(e); } } /** * Setup/configure header section-title's backlink chevron */ setupBacklink() { document.querySelector('a#backlink').addEventListener('click', _ => { if (document.referrer.length > 0 && !document.referrer.startsWith(document.location.origin)) { this.router.navigate('/'); return; } window.history.back(); }); } } window.gulliver = new Gulliver(); ================================================ FILE: public/js/loader.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import './util/requestIdleCallback'; const FADE_OUT_ANIMATION_LENGTH = 500; /** * A CSS only loader showing three dots. */ class Loader { /** * Create a new loader. * * @param container {HTMLElement} the element containing the loader * @param style {String} optional hex color or css class for styling the loader */ constructor(container, style) { this.style = style || ''; this.container = container; } /** * addLoader adds a CSS loader to the given element. * * @param container {HTMLElement} the element containing the loader. */ show() { const loader = document.createElement('div'); loader.style['align-items'] = 'center'; loader.classList.add('loader'); for (let i = 0; i < 3; i++) { const dot = document.createElement('div'); dot.classList.add('loader-dot'); if (this.style.startsWith('#')) { dot.style['background-color'] = this.style; } else if (this.style) { dot.classList.add(this.style); } loader.appendChild(dot); } this.container.appendChild(loader); } /** * removeLoader removes a CSS loader from the given element. * * @param container {HTMLElement} the element containing the loader. */ hide() { const loaders = this.container.querySelectorAll('.loader'); loaders.forEach(loader => { loader.classList.add('fadeOut'); window.requestIdleCallback(() => loader.remove(), { timeout: FADE_OUT_ANIMATION_LENGTH }); }); } } export default Loader; ================================================ FILE: public/js/messaging.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import 'whatwg-fetch/fetch'; // Imports `fetch` polyfill import firebase from 'firebase/app'; import 'firebase/messaging'; const SUBSCRIBE_ENDPOINT = '/api/notifications/subscribe'; const UNSUBSCRIBE_ENDPOINT = '/api/notifications/unsubscribe'; const TOPICS_ENDPOINT = '/api/notifications/topics'; const ERROR_PERMISSION_BLOCKED = 'messaging/permission-blocked'; const ERROR_NOTIFICATIONS_BLOCKED = 'messaging/notifications-blocked'; // const ERROR_PERMISSION_DEFAULT = 'messaging/permission-default'; export default class Messaging { constructor(messagingSenderId) { const config = { messagingSenderId: messagingSenderId }; firebase.initializeApp(config); } /** * Fetches from url, adding token to the request body * as a JSON * * @param url - the url to fetch from * @param token - the token to be sent to the server */ _postWithToken(url, token) { return fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({token: token}) }); } _checkBlockedNotification(err) { return err.code === ERROR_PERMISSION_BLOCKED || err.code === ERROR_NOTIFICATIONS_BLOCKED; } /** * Enables Notifications to a topic. * Will ask user permission, if needed and then subscribe * to a topic on the server. * * @param topic - The topic to subscribe to * @returns a Promise */ subscribe(topic) { console.log('Subscribing to: ' + topic); const messaging = firebase.messaging(); return messaging.requestPermission() .then(() => { return messaging.getToken(); }) .then(token => { console.log(token); const url = SUBSCRIBE_ENDPOINT + '/' + topic; return this._postWithToken(url, token); }) .catch(err => { if (this._checkBlockedNotification(err)) { err.blocked = true; } return Promise.reject(err); }); } /** * Disables Notifications from a topic. * * @param topic - The topic to unsubscribe from. * @returns Promise */ unsubscribe(topic) { console.log('Unsubscribing from: ' + topic); const messaging = firebase.messaging(); return messaging.getToken() .then(token => { const url = UNSUBSCRIBE_ENDPOINT + '/' + topic; return this._postWithToken(url, token); }).catch(err => { if (this._checkBlockedNotification(err)) { err.blocked = true; } return Promise.reject(err); }); } /** * Gets all topics user has subscribed for * * @returns Promise> */ getSubscriptions() { const messaging = firebase.messaging(); return messaging.getToken() .then(token => { if (!token) { return Promise.resolve([]); } const url = TOPICS_ENDPOINT + '?token=' + token; return fetch(url, {headers: {Accept: 'application/json'}}) .then(response => { if (response.status !== 200) { return []; } return response.json() .then(json => { return json.subscriptions; }); }); }) .catch(err => { console.error('Error fetching subscriptions: ', err); return Promise.resolve([]); }); } /** * Checks if user is subscribed to a topic. * @param topic - The topic to check for subscriptions. * @returns Promise - true if subscribed, false if not. */ isSubscribed(topic) { return this.getSubscriptions() .then(subscriptions => { return subscriptions.indexOf(topic) >= 0; }); } isNotificationBlocked() { const messaging = firebase.messaging(); return messaging.getToken() .then(_ => { return false; }) .catch(err => { if (err.code === ERROR_NOTIFICATIONS_BLOCKED) { return true; } return Promise.reject(err); }); } } ================================================ FILE: public/js/offline-support.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export default class OfflineSupport { constructor(window, router) { this.window = window; this.router = router; this._setupEventhandlers(); } /** * All elements with class .gulliver-online-aware will: * have an 'online' dataset property that reflects the current online state. * receive a 'change' event whenever the state changes. */ _setupEventhandlers() { const body = this.window.document.querySelector('body'); this.window.addEventListener('online', () => { body.removeAttribute('offline'); }); this.window.addEventListener('offline', () => { body.setAttribute('offline', 'true'); this.markAsCached(this.window.document.querySelectorAll('.offline-aware')); }); const onLine = this.window.navigator.onLine; if (onLine !== undefined && !onLine) { body.setAttribute('offline', 'true'); } } /** * Check if a Url is navigable. * @param url the url to be checke for availability * @returns true if the user is online or the URL is cached */ isAvailable(href) { if (!href || this.window.navigator.onLine) return Promise.resolve(true); return caches.match(href) .then(response => response.status === 200) .catch(() => false); } /** * Checks if the href on the anchor is available in the cached * and marks the element with the cached attribute. * * If the url is available, the `cached` attribute is added with * the value `true`. Otherwise, the `cached` attribute is removed. * @param {@NodeList} a list of anchors. */ markAsCached(anchors) { anchors.forEach(anchor => { if (!anchor.href) { return; } const route = this.router.findRoute(anchor.href); if (!route) { return; } const contentHref = route.getContentOnlyUrl(anchor.href); this.isAvailable(contentHref).then(available => { if (available) { anchor.setAttribute('cached', 'true'); return; } anchor.removeAttribute('cached'); }); }); } } ================================================ FILE: public/js/pwa-form.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import Loader from './loader'; // SVG from https://svgsilh.com/svg/2026645.svg const SVG = ''; export default class PwaForm { constructor(window, signIn) { this._window = window; this._signIn = signIn; } setup() { console.log('Setting up PWA Form'); this._pwaForm = document.querySelector('#pwaForm'); if (!this._pwaForm) { console.log('%c#pwaForm not found.', 'color:red'); return; } this._manifestUrlInput = document.querySelector('#manifestUrl'); if (!this._manifestUrlInput) { console.log('%c#manifestUrl input not found.', 'color:red'); return; } this._pageContainer = document.querySelector('.items'); this._loadingTemplate = document.querySelector('#template-load-pwa'). content.querySelector('a'); this._setupListeners(); } _addPwa(container, manifestUrl) { console.log(container); const icon = container.querySelector('.icon'); const text = container.querySelector('.pwa-name'); const loader = new Loader(icon); text.innerText = manifestUrl; loader.show(); fetch('/api/pwa/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idToken: this._signIn.idToken, manifestUrl: manifestUrl }) }) .then(response => response.json()) .then(json => { loader.hide(); if(json.error) { loader.hide(); text.innerText = `Error: ${json.error}`; icon.innerHTML = SVG; return; } text.innerText = json.name; icon.innerText = json.name[0]; container.setAttribute('href', '/pwas/' + json.id); container.classList.remove('link-disabled'); container.style['background-color'] = json.backgroundColor; icon.style['color'] = json.foregroundColor; text.style['color'] = json.foregroundColor; }) .catch(err => { loader.hide(); text.innerText = `Error: ${text.innerText}`; icon.innerHTML = SVG; console.log(err); }) } /** * Sets up a listeners for events. */ _setupListeners() { this._pwaForm.addEventListener('submit', event => { event.preventDefault(); const newLoading = this._loadingTemplate.cloneNode(true); this._pageContainer.appendChild(newLoading); this._addPwa(newLoading, this._manifestUrlInput.value); this._pwaForm.reset(); return false; }); // Setup listener for the userchange event. this._window.addEventListener('userchange', () => { console.log(this._signIn.signedIn); }); } } ================================================ FILE: public/js/routing/route.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import 'url-polyfill/url-polyfill'; export default class Route { constructor(matchRegex, transitionStrategy, onAttached) { this._transitionStrategy = transitionStrategy; this._matchRegex = matchRegex; this._onAttached = onAttached; } matches(url) { return this._matchRegex.test(url); } retrieveContent(url) { const contentUrl = this.getContentOnlyUrl(url); return fetch(contentUrl) .then(response => response.text()); } transitionOut(container) { this._transitionStrategy.transitionOut(container); } transitionIn(container) { this._transitionStrategy.transitionIn(container); } onAttached() { if (this._onAttached && Array.isArray(this._onAttached)) { this._onAttached.forEach(onAttached => { onAttached && onAttached(); }); return; } return this._onAttached && this._onAttached(); } getContentOnlyUrl(url) { const u = new URL(url); u.searchParams.append('contentOnly', 'true'); return u.toString(); } } ================================================ FILE: public/js/routing/router.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import EventTarget from '../event-target'; export default class Router { constructor(window, container) { this._routes = []; this._window = window; this._container = container; this._document = window.document; this._eventTarget = new EventTarget(); // Update UI when back is pressed. this._window.addEventListener('popstate', this._updateContent.bind(this)); this._takeOverAnchorLinks(this._window.document); } findRoute(url) { return this._routes.find(route => route.matches(url)); } addEventListener(type, callback) { this._eventTarget.addEventListener(type, callback); } _updateContent() { const location = this._window.document.location.href; const route = this.findRoute(location); if (!route) { console.error('Url did not match any router: ', location); // TODO: navigate to 404? return; } this._window.scrollTo(0, 0); route.transitionOut(this._container); route.retrieveContent(location) .then(content => { this._container.innerHTML = content; route.transitionIn(this._container); this._takeOverAnchorLinks(this._container); route.onAttached(); this._dispatchNavigateEvent(location, route); }) .catch(err => { console.error('Error getting page content for: ', location, ' Error: ', err); }); } addRoute(route) { this._routes.push(route); } navigate(url) { console.log('Navigating To: ', url); this._window.history.pushState(null, null, url); this._updateContent(); } setupInitialRoute() { const body = this._document.querySelector('body'); if (body.hasAttribute('data-empty-shell')) { this._updateContent(); return; } const location = this._document.location.href; const route = this.findRoute(location); this._takeOverAnchorLinks(this._container); route.onAttached(); } _dispatchNavigateEvent(url, route) { const event = this._document.createEvent('CustomEvent'); const detail = { url: url, route: route }; event.initCustomEvent( 'navigate', /* bubbles */ false, /* cancelable */ false, detail); this._eventTarget.dispatchEvent(event); } _dispatchOutboundNavigationEvent(url) { const event = this._document.createEvent('CustomEvent'); const detail = { url: url }; event.initCustomEvent('navigateoutbound', false, false, detail); this._eventTarget.dispatchEvent(event); } _isNotLeftClickWithoutModifiers(e) { return e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey; } _takeOverAnchorLinks(root) { root.querySelectorAll('a').forEach(element => { element.addEventListener('click', e => { if (this._isNotLeftClickWithoutModifiers(e)) { return true; } // Link does not have an url. if (!e.currentTarget.href) { return true; } // Never catch links to external websites. if (!e.currentTarget.href.startsWith(this._window.location.origin)) { this._dispatchOutboundNavigationEvent(e.currentTarget.href); return true; } // Check if there's a route for this url. const route = this.findRoute(e.currentTarget.href); if (!route) { return true; } e.preventDefault(); this.navigate(e.currentTarget.href); return false; }); }); } } ================================================ FILE: public/js/routing/transitions.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import Loader from '../loader'; export class FadeInOutTransitionStrategy { transitionIn(container) { container.classList.remove('transition'); } transitionOut(container) { container.classList.add('transition'); } } export class LoaderTransitionStrategy { constructor(window) { this._window = window; const loaderDiv = window.document.querySelector('.page-loader'); this._loader = new Loader(loaderDiv); } transitionIn(container) { container.classList.remove('transition'); this._loader.hide(); } transitionOut(container) { container.classList.add('transition'); this._loader.show(); } } ================================================ FILE: public/js/search-input.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * Generate gulliver.js from this file via `npm prestart`. (`npm start` will run * `prestart` automatically.) */ /* eslint-env browser */ class SearchButton { /** * Setup/configure search button */ setupSearchElements(router) { const eventHandler = event => { event.preventDefault(); const searchValue = document.querySelector('#search-input').value; if (searchValue.length === 0) { router.navigate('/'); } else { const urlParams = new URLSearchParams(window.location.search); // Only navigate if the search query changes if (searchValue !== urlParams.get('query')) { router.navigate('/pwas/search?query=' + searchValue); } document.querySelector('#search-input').blur(); } }; document.querySelector('#search').addEventListener('submit', eventHandler); } } export default new SearchButton(); ================================================ FILE: public/js/shell.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export default class Shell { constructor(document) { this._document = document; this._backlink = document.querySelector('#backlink'); this._tabs = Array.from(document.querySelectorAll('#installable, #newest, #score, #tabs')); this._subtitle = document.querySelector('#subtitle'); this._search = document.querySelector('#search'); this._states = new Map(); } setStateForRoute(route, shellState) { this._states.set(route, shellState); } _showElement(element, visible) { if (visible) { element.classList.remove('hidden'); return; } element.classList.add('hidden'); } _updateTab(tab, options) { this._showElement(tab, options.showTabs); if (!options.currentTab) { return; } if (tab.id === options.currentTab) { tab.classList.add('activetab'); return; } tab.classList.remove('activetab'); } onRouteChange(route) { const options = this._states.get(route); this._showElement(this._backlink, options.backlink); this._showElement(this._subtitle, options.subtitle); this._showElement(this._search, options.search); this._tabs.forEach(tab => this._updateTab(tab, options)); } } ================================================ FILE: public/js/signin.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ import {authInit} from './gapi.es6.js'; export default class SignIn { constructor(window, config) { this.window = window; this.config = config; this._init(); this._setupEventHandlers(); } _init() { /* eslint-disable camelcase */ const params = { scope: 'profile', client_id: this.config.client_id, fetch_basic_profile: false }; /* eslint-enable camelcase */ return authInit(params).then(auth => { this.auth = auth; this._setupUserChangeEvents(auth); return this; }); } _setupUserChangeEvents(auth) { this.window.auth = auth; // TODO: Temporary Hack to Make 'ui/client-transition.js' work. // Fire 'userchange' event on page load (not just when status changes) this.window.dispatchEvent(new CustomEvent('userchange', { detail: auth.currentUser.get() })); // Fire 'userchange' event when status changes auth.currentUser.listen(user => { window.dispatchEvent(new CustomEvent('userchange', { detail: user })); }); } get signedIn() { return this.user && this.user.isSignedIn(); } get user() { if (!this.auth) { return null; } return this.auth.currentUser.get(); } get idToken() { if (!this.signedIn) { return null; } return this.user.getAuthResponse().id_token; } signIn() { if (!this.auth) { console.log('Auth not ready!'); return; } this.auth.signIn(); } signOut() { if (!this.auth) { console.log('Auth not ready!'); return; } this.auth.signOut(); } /** * All elements with class .gulliver-signedin-aware will: * have a 'signedin' dataset property that reflects the current signed in state. * receive a 'change' event whenever the state changes. */ _setupEventHandlers() { const body = this.window.document.querySelector('body'); this.window.addEventListener('userchange', e => { const user = e.detail; if (user.isSignedIn()) { body.setAttribute('signedIn', 'true'); } else { body.removeAttribute('signedIn'); } }); } } ================================================ FILE: public/js/ui/notification-checkbox.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export default class NotificationCheckbox { constructor(messaging, checkbox, topic) { if (!checkbox) { console.error('checkbox parameter cannot be null'); return; } this.messaging = messaging; this.checkbox = checkbox; this.topic = topic; this._setupEventListener(); // Initilize checkbox state. this.messaging.isNotificationBlocked() .then(blocked => { if (blocked) { checkbox.disabled = true; return; } this.messaging.isSubscribed(topic) .then(subscribed => { checkbox.checked = subscribed; }); }); } _setupEventListener() { this.checkbox.addEventListener('change', e => { if (e.target.checked) { this.messaging.subscribe(this.topic) .catch(e => { console.error('Error subscribing to topic: ', e); this.checkbox.checked = false; if (e.blocked) { this.checkbox.disabled = true; } }); return; } this.messaging.unsubscribe(this.topic) .catch(err => { console.error('Error unsubscribing from topic: ', err); if (err.blocked) { this.checkbox.disabled = true; this.checkbox.checked = false; return; } this.checkbox.checked = true; return; }); }); } } ================================================ FILE: public/js/ui/share-button.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export class ShareButton { constructor(window, element, nameElement) { this.element = element; this._nameElement = nameElement; this._window = window; this._init(); } _init() { if (!this._window.navigator.share) { return; } this.element.classList.remove('hidden'); this._setupEventListeners(); } _setupEventListeners() { const clickListener = () => { this.share(); }; this.element.addEventListener('click', clickListener); } _getTitle() { const pwaName = this._window.document.querySelector('#pwa-name'); if (!pwaName) { return 'PWA Directory'; } return pwaName.innerText.trim(); } share() { const title = this._getTitle(); this._window.navigator.share({ title, url: this._window.location.href }).catch(err => { console.log(`Share failed, reason: ${err}`); }); } } ================================================ FILE: public/js/ui/signin-button.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint-env browser */ export class SignInButton { constructor(window, signIn, element) { this.signIn = signIn; this.element = element; this._window = window; this._setupEventListeners(); } _setupEventListeners() { // Make SignIn button react to userchange events. this._window.addEventListener('userchange', () => { if (this.signIn.signedIn) { this.element.classList.add('hidden'); } else { this.element.classList.remove('hidden'); } }); const clickListener = () => { if (!this.signIn.signedIn) { this.signIn.signIn(); } }; this.element.addEventListener('click', clickListener); } } export class SignOutButton { constructor(window, signIn, element) { this.signIn = signIn; this.element = element; this._window = window; this._setupEventListeners(); } _setupEventListeners() { // Make SignOut button react to userchange events. this._window.addEventListener('userchange', () => { if (this.signIn.signedIn) { this.element.classList.remove('hidden'); } else { this.element.classList.add('hidden'); } }); const clickListener = () => { if (this.signIn.signedIn) { this.signIn.signOut(); } }; this.element.addEventListener('click', clickListener); } } ================================================ FILE: public/js/util/requestIdleCallback.js ================================================ /*! * Copyright 2015 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing * permissions and limitations under the License. */ /* eslint-env browser */ /* * @see https://developers.google.com/web/updates/2015/08/using-requestidlecallback */ window.requestIdleCallback = window.requestIdleCallback || (cb => { return setTimeout(_ => { let start = Date.now(); cb({ didTimeout: false, timeRemaining: _ => { return Math.max(0, 50 - (Date.now() - start)); } }); }, 1); }); window.cancelIdleCallback = window.cancelIdleCallback || (id => { clearTimeout(id); }); ================================================ FILE: public/manifest.json ================================================ { "name": "PWA Directory", "short_name": "PwaDirectory", "description": "A Directory of PWAs", "start_url": "/?utm_source=homescreen", "icons": [ { "src": "\/favicons\/android-chrome-192x192.png", "sizes": "192x192", "type": "image\/png" }, { "src": "\/favicons\/android-chrome-512x512.png", "sizes": "512x512", "type": "image\/png" } ], "theme_color": "#7cc0ff", "background_color": "#7cc0ff", "display": "standalone", "scope": "/", "share_target": { "url_template": "/pwas/add?url={url}" }, "//": "Some browsers will use this to enable push notifications.", "//": "It is the same for all projects, this is not your project's sender ID", "gcm_sender_id": "103953800507" } ================================================ FILE: public/robots.txt ================================================ User-Agent: * Disallow: ================================================ FILE: public/sw.js ================================================ /* eslint-env serviceworker, browser */ // sw-offline-google-analytics *must* be imported and initialized before // sw-toolbox, because its 'fetch' event handler needs to run first. importScripts('/sw-offline-google-analytics/offline-google-analytics-import.js'); goog.offlineGoogleAnalytics.initialize(); // Use sw-toolbox importScripts('/sw-toolbox/sw-toolbox.js'); /* global toolbox */ toolbox.options.debug = false; importScripts('/js/sw-assets-precache.js'); /* global ASSETS */ const VERSION = '24'; const PREFIX = 'gulliver'; const CACHE_NAME = `${PREFIX}-v${VERSION}`; const PWA_OPTION = { cache: { name: `PWA-${CACHE_NAME}`, maxAgeSeconds: 60 * 60 * 12, queryOptions: { ignoreSearch: true } } }; const PWA_LIST_OPTION = { cache: { name: `LIST-${CACHE_NAME}`, maxAgeSeconds: 60 * 60 * 6 } }; // URL to return in place of the "offline dino" when client is // offline and requests a URL that's not in the cache. const OFFLINE_URL = '/.app/offline'; const SHELL_URL = '/.app/shell'; const OFFLINE = [ OFFLINE_URL, SHELL_URL, '/?cacheOnly=true', '/favicons/android-chrome-72x72.png', '/manifest.json', '/img/GitHub-Mark-Light-24px.png', '/img/GitHub-Mark-Light-48px.png', '/img/lighthouse-18.png', '/img/lighthouse-36.png', '/messaging-config.json' ]; toolbox.precache(OFFLINE.concat(ASSETS)); toolbox.options.cache.name = CACHE_NAME; /** * Utility method to retrieve a url from the `toolbox.options.cache.name` cache * * @param {*} url url to be requested from the cache. */ const getFromCache = url => { return caches.open(toolbox.options.cache.name) .then(cache => cache.match(url)); }; /** * A sw-toolbox handler that tries to serve content using networkFirst, and if * it fails, returns a custom offline page. */ const gulliverHandler = (request, values, options) => { return toolbox.fastest(request, values, options) .catch(_ => { // networkFirst failed (no network and not in cache) getFromCache(OFFLINE_URL).then(response => { return response || new Response('', { status: 500, statusText: 'Offline Page Missing' }); }); }); }; const getContentOnlyUrl = url => { const u = new URL(url); u.searchParams.append('contentOnly', 'true'); return u.toString(); }; toolbox.router.default = (request, values, options) => { if (request.mode === 'navigate') { // Launch and early request to the content URL that will be loaded from the shell. // Since the response has a short timeout, the browser will re-use the request. toolbox.cacheFirst(new Request(getContentOnlyUrl(request.url)), values, options); // Replace the request with the App Shell. return getFromCache(SHELL_URL) .then(response => response || gulliverHandler(request, values, options)); } return gulliverHandler(request, values, options); }; toolbox.router.get(/\/pwas\/\d+/, toolbox.router.default, PWA_OPTION); toolbox.router.get('/pwas/score', toolbox.router.default, PWA_LIST_OPTION); toolbox.router.get('/pwas/newest', toolbox.router.default, PWA_LIST_OPTION); toolbox.router.get('/', (request, values) => { // Replace requests to start_url with the lastest version of the root page. // TODO Make more generic: strip utm_* parameters from *every* request. // TODO Pass through credentials (e.g. cookies) and other request metadata, see // https://github.com/ithinkihaveacat/sw-proxy/blob/master/http-proxy.ts#L249. if (request.url.endsWith('/?utm_source=homescreen')) { request = new Request('/'); } return toolbox.router.default(request, values, PWA_LIST_OPTION); }); toolbox.router.get(/.*\.(js|png|svg|jpg|css)$/, (request, values, options) => { return toolbox.cacheFirst(request, values, options); }); // API request bypass the Shell toolbox.router.get(/\/api\/.*/, (request, values, options) => { return toolbox.networkFirst(request, values, options); }); // Claim all clients and delete old caches that are no longer needed. self.addEventListener('activate', event => { self.clients.claim(); event.waitUntil( caches.keys().then(cacheNames => Promise.all( cacheNames.filter(cacheName => cacheName !== CACHE_NAME && cacheName !== PWA_OPTION.name && cacheName !== PWA_LIST_OPTION.name) .map(cacheName => caches.delete(cacheName)) ) ) ); }); // Make sure the SW the page we register() is the service we use. self.addEventListener('install', () => self.skipWaiting()); ================================================ FILE: rollup-config/gulliver.js ================================================ import babel from 'rollup-plugin-babel'; import uglify from 'rollup-plugin-uglify'; import nodeResolve from 'rollup-plugin-node-resolve'; import commonsjs from 'rollup-plugin-commonjs'; export default { entry: './public/js/gulliver.es6.js', plugins: [ babel({exclude: 'node_modules/**'}), uglify(), nodeResolve(), commonsjs() ], // Quiet warning: https://github.com/rollup/rollup/wiki/Troubleshooting#this-is-undefined context: 'window', targets: [ { dest: './public/js/gulliver.js', // Fixes 'navigator' not defined when using Firebase and strict mode: // http://stackoverflow.com/questions/31221357/webpack-firebase-disable-parsing-of-firebase useStrict: false, format: 'iife', sourceMap: true } ] }; ================================================ FILE: rollup-config/lighthouse-chart.js ================================================ import babel from 'rollup-plugin-babel'; import uglify from 'rollup-plugin-uglify'; import nodeResolve from 'rollup-plugin-node-resolve'; import commonsjs from 'rollup-plugin-commonjs'; export default { entry: './public/js/lighthouse-chart.es6.js', plugins: [ babel({exclude: 'node_modules/**'}), uglify(), nodeResolve(), commonsjs() ], // Quiet warning: https://github.com/rollup/rollup/wiki/Troubleshooting#this-is-undefined context: 'window', targets: [ { dest: './public/js/lighthouse-chart.js', // Fixes 'navigator' not defined when using Firebase and strict mode: // http://stackoverflow.com/questions/31221357/webpack-firebase-disable-parsing-of-firebase useStrict: false, format: 'iife', sourceMap: true } ] }; ================================================ FILE: rollup-config/pwa-form.js ================================================ import babel from 'rollup-plugin-babel'; import uglify from 'rollup-plugin-uglify'; import nodeResolve from 'rollup-plugin-node-resolve'; import commonsjs from 'rollup-plugin-commonjs'; export default { entry: './public/js/pwa-form.es6.js', plugins: [ babel({exclude: 'node_modules/**'}), uglify(), nodeResolve(), commonsjs() ], // Quiet warning: https://github.com/rollup/rollup/wiki/Troubleshooting#this-is-undefined context: 'window', targets: [ { dest: './public/js/pwa-form.js', // Fixes 'navigator' not defined when using Firebase and strict mode: // http://stackoverflow.com/questions/31221357/webpack-firebase-disable-parsing-of-firebase useStrict: false, format: 'iife', sourceMap: true } ] }; ================================================ FILE: test/app/controllers/api/favorite-pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before afterEach */ 'use strict'; const controllerApi = require('../../../../controllers/api'); const libFavoritePwa = require('../../../../lib/favorite-pwa'); const verifyIdToken = require('../../../../lib/verify-id-token'); const express = require('express'); const app = express(); const request = require('supertest'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); let assert = require('chai').assert; describe('controllers.api.favorite-pwa', () => { before(done => { app.use(controllerApi); done(); }); describe('GET /api/favorite-pwa/', () => { afterEach(() => { simpleMock.restore(); }); const testGoogleLogin = {}; testGoogleLogin.getPayload = () => { return {sub: '1234567890'}; }; it('respond with 401 if missing user idToken', done => { // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa') .expect('Content-Type', /json/) .expect(401).should.be.fulfilled.then(res => { assert.equal(res.body, '401 Unauthorized'); done(); }); }); it('respond with 200 if Favorite PWA exist', done => { simpleMock.mock(libFavoritePwa, 'findByUserId').resolveWith('list of favorite pwas'); simpleMock.mock(verifyIdToken, 'verifyIdToken').resolveWith(testGoogleLogin); // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa?idToken=1234567890').set('Authorization', 'ID_TOKEN') .expect('Content-Type', /json/) .expect(200).should.be.fulfilled.then(res => { assert.equal(res.body, 'list of favorite pwas'); assert.equal(libFavoritePwa.findByUserId.callCount, 1); assert.equal(libFavoritePwa.findByUserId.lastCall.arg, '01b307acba4f54f55aafc33bb06bbbf6ca803e9a'); done(); }); }); it('respond with 404 if Favorite PWA does not exist', done => { simpleMock.mock(libFavoritePwa, 'findByUserId').resolveWith(null); simpleMock.mock(verifyIdToken, 'verifyIdToken').resolveWith(testGoogleLogin); // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa?idToken=1234567890').set('Authorization', 'ID_TOKEN') .expect('Content-Type', /json/) .expect(404).should.be.fulfilled.then(res => { assert.equal(res.body, 'not found'); assert.equal(libFavoritePwa.findByUserId.callCount, 1); assert.equal(libFavoritePwa.findByUserId.lastCall.arg, '01b307acba4f54f55aafc33bb06bbbf6ca803e9a'); done(); }); }); }); describe('GET /api/favorite-pwa/:pwaId', () => { afterEach(() => { simpleMock.restore(); }); const testGoogleLogin = {}; testGoogleLogin.getPayload = () => { return {sub: '1234567890'}; }; it('respond with 401 if missing user idToken', done => { // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa/1234567') .expect('Content-Type', /json/) .expect(401).should.be.fulfilled.then(res => { assert.equal(res.body, '401 Unauthorized'); done(); }); }); it('respond with 200 if findFavoritePwa exist', done => { simpleMock.mock(libFavoritePwa, 'findFavoritePwa').resolveWith('list of favorite pwas'); simpleMock.mock(verifyIdToken, 'verifyIdToken').resolveWith(testGoogleLogin); // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa/1234567').set('Authorization', 'ID_TOKEN') .expect('Content-Type', /json/) .expect(200).should.be.fulfilled.then(res => { assert.equal(res.body, 'list of favorite pwas'); assert.equal(libFavoritePwa.findFavoritePwa.callCount, 1); assert.equal(libFavoritePwa.findFavoritePwa.lastCall.args[0], '1234567'); assert.equal(libFavoritePwa.findFavoritePwa.lastCall.args[1], '01b307acba4f54f55aafc33bb06bbbf6ca803e9a'); done(); }); }); it('respond with 404 if findFavoritePwa does not exist', done => { simpleMock.mock(libFavoritePwa, 'findFavoritePwa').resolveWith(null); simpleMock.mock(verifyIdToken, 'verifyIdToken').resolveWith(testGoogleLogin); // /api/ is part of the router, we need to start from /favorite-pwa/ request(app) .get('/favorite-pwa/1234567').set('Authorization', 'ID_TOKEN') .expect('Content-Type', /json/) .expect(404).should.be.fulfilled.then(res => { assert.equal(res.body, 'not found'); assert.equal(libFavoritePwa.findFavoritePwa.callCount, 1); assert.equal(libFavoritePwa.findFavoritePwa.lastCall.args[0], '1234567'); assert.equal(libFavoritePwa.findFavoritePwa.lastCall.args[1], '01b307acba4f54f55aafc33bb06bbbf6ca803e9a'); done(); }); }); }); }); ================================================ FILE: test/app/controllers/api/lighthouse.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before afterEach */ 'use strict'; const controllerApi = require('../../../../controllers/api'); const lighthouseLib = require('../../../../lib/lighthouse'); const express = require('express'); const app = express(); const request = require('supertest'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); let assert = require('chai').assert; describe('controllers.api.lighthouse', () => { before(done => { app.use(controllerApi); done(); }); describe('GET /api/lighthouse-graph/', () => { afterEach(() => { simpleMock.restore(); }); it('respond with 200 if PWA exist', done => { simpleMock.mock(lighthouseLib, 'getLighthouseGraphByPwaId').resolveWith('mocked graph data'); // /api/ is part of the router, we need to start from /lighthouse-graph/ request(app) .get('/lighthouse/graph/1234567') .expect('Content-Type', /json/) .expect(200).should.be.fulfilled.then(res => { assert.equal(res.body, 'mocked graph data'); assert.equal(lighthouseLib.getLighthouseGraphByPwaId.callCount, 1); assert.equal(lighthouseLib.getLighthouseGraphByPwaId.lastCall.arg, 1234567); done(); }); }); it('respond with 404 if PWA does not exist', done => { simpleMock.mock(lighthouseLib, 'getLighthouseGraphByPwaId').resolveWith(null); // /api/ is part of the router, we need to start from /lighthouse-graph/ request(app) .get('/lighthouse/graph/123') .expect('Content-Type', /json/) .expect(400).should.be.rejected.then(res => { assert.equal(res.body, undefined); assert.equal(lighthouseLib.getLighthouseGraphByPwaId.callCount, 1); assert.equal(lighthouseLib.getLighthouseGraphByPwaId.lastCall.arg, '123'); done(); }); }); }); }); ================================================ FILE: test/app/controllers/api/pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before afterEach */ 'use strict'; const controllerApi = require('../../../../controllers/api'); const libPwa = require('../../../../lib/pwa'); const testPwa = require('../../models/pwa'); const config = require('../../../../config/config'); const apiKeyArray = config.get('API_TOKENS'); const express = require('express'); const app = express(); const request = require('supertest'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); let assert = require('chai').assert; const MANIFEST_URL = 'https://pwa-directory.appspot.com/manifest.json'; /* eslint-disable camelcase */ const MANIFEST_DATA = { name: 'PWA Directory', short_name: 'PwaDirectory', start_url: '/?utm_source=homescreen' }; describe('controllers.api.pwa', () => { before(done => { app.use(controllerApi); done(); }); describe('GET /api/pwa', () => { const pwa = testPwa.newPwa(MANIFEST_URL, MANIFEST_DATA); pwa.id = '789'; const result = {}; result.pwas = [pwa]; afterEach(() => { simpleMock.restore(); }); it('respond with 200 and json', done => { simpleMock.mock(libPwa, 'list').resolveWith(Promise.resolve(result)); // /api/ is part of the router, we need to start from /pwa/ request(app) .get('/pwa?key=' + apiKeyArray[0]) .expect(200) .expect('Content-Type', /json/).should.be.fulfilled.then(_ => { assert.equal(libPwa.list.callCount, 1); done(); }); }); it('respond with 200 and csv', done => { simpleMock.mock(libPwa, 'list').resolveWith(Promise.resolve(result)); // /api/ is part of the router, we need to start from /pwa/ request(app) .get('/pwa?format=csv&key=' + apiKeyArray[0]) .expect(200) .expect('Content-Type', 'text/csv; charset=utf-8').should.be.fulfilled.then(_ => { assert.equal(libPwa.list.callCount, 1); done(); }); }); }); }); ================================================ FILE: test/app/controllers/cache.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before afterEach */ 'use strict'; const controllersCache = require('../../../controllers/cache'); const libCache = require('../../../lib/data-cache'); const express = require('express'); const app = express(); const request = require('supertest'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); const assert = require('chai').assert; describe('controllers.cache', () => { before(done => { app.use('/', controllersCache, (req, res) => { res.send('PageRendered'); } ); done(); }); describe('GET /', () => { afterEach(() => { simpleMock.restore(); }); it('Page from cache', done => { simpleMock.mock(libCache, 'get').resolveWith('PageFromCache'); simpleMock.mock(libCache, 'set').resolveWith(); request(app) .get('/') .expect(200).should.be.fulfilled.then(res => { assert.equal(libCache.get.callCount, 1); assert.equal(res.text, 'PageFromCache'); done(); }); }); it('Not in cache, rendered directly', done => { simpleMock.mock(libCache, 'get').rejectWith('Not in cache').resolveWith('/'); simpleMock.mock(libCache, 'set').resolveWith(); simpleMock.mock(libCache, 'storeCachedUrls').resolveWith(); request(app) .get('/') .expect(200).should.be.fulfilled.then(res => { assert.equal(res.text, 'PageRendered'); assert.equal(libCache.get.callCount, 1); assert.equal(libCache.get.calls[0].args[0], '/'); assert.equal(libCache.set.callCount, 1); assert.equal(libCache.set.calls[0].args[0], '/'); assert.equal(libCache.set.calls[0].args[1], 'PageRendered'); assert.equal(libCache.storeCachedUrls.callCount, 1); assert.equal(libCache.storeCachedUrls.calls[0].args[0], '/'); done(); }) .catch(err => { console.log(err); }); }); }); }); ================================================ FILE: test/app/controllers/tasks.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before afterEach */ 'use strict'; const controllerTasks = require('../../../controllers/tasks'); const tasksLib = require('../../../lib/tasks'); const pwaLib = require('../../../lib/pwa'); const Pwa = require('../../../models/pwa'); const express = require('express'); const app = express(); const request = require('supertest'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); const assert = require('chai').assert; const APP_ENGINE_CRON = 'X-Appengine-Cron'; const MANIFEST_URL = 'https://pwa-directory.appspot.com/manifest.json'; describe('controllers.tasks', () => { let listPwas = {}; before(done => { app.use(controllerTasks); let pwa1 = new Pwa(MANIFEST_URL, null); pwa1.id = 123456789; let pwa2 = new Pwa(MANIFEST_URL, null); pwa2.id = 234567890; pwa2.lighthouseScore = 99; listPwas.pwas = [pwa1, pwa2]; done(); }); describe('GET /tasks/cron', () => { afterEach(() => { simpleMock.restore(); }); it('respond with 403 forbidden when X-Appengine-Cron not present', done => { request(app) .get('/cron') .expect(403, done); }); }); describe('GET /tasks/updateunscored', () => { afterEach(() => { simpleMock.restore(); }); it('respond with 403 forbidden when X-Appengine-Cron not present', done => { request(app) .get('/updateunscored') .expect(403, done); }); it('respond with 200 when X-Appengine-Cron is present', done => { simpleMock.mock(tasksLib, 'push').resolveWith(null); simpleMock.mock(pwaLib, 'list').resolveWith(listPwas); request(app) .get('/updateunscored') .set(APP_ENGINE_CRON, true) .expect(200).should.be.fulfilled.then(_ => { assert.equal(pwaLib.list.callCount, 1); assert.equal(tasksLib.push.callCount, 1); done(); }); }); }); describe('GET /tasks/execute', () => { afterEach(() => { simpleMock.restore(); }); it('respond with 403 forbidden when X-Appengine-Cron not present', done => { request(app) .get('/execute') .expect(403, done); }); }); }); ================================================ FILE: test/app/lib/asset-hashing.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it beforeEach afterEach*/ 'use strict'; const assert = require('chai').assert; const simple = require('simple-mock'); const assetHashing = require('../../../lib/asset-hashing'); describe('ChecksumProvider', () => { it('calculates checksum', () => { const checksumProvider = new assetHashing.ChecksumProvider(__dirname); assert.equal(checksumProvider.get('asset-hashing.js').length, assetHashing.CHECKSUM_LENGTH); }); }); describe('AssetChecksum', () => { let checksumProvider = new assetHashing.ChecksumProvider(); let asset; beforeEach(() => { simple.mock(checksumProvider, 'get', () => '1234567890'); asset = new assetHashing.AssetChecksum(checksumProvider); }); describe('encode', () => { it('adds checksum to file name', () => { assert.equal(asset.encode('public/style.css'), 'public/style.1234567890.css'); }); it('ignores dirs', () => { assert.equal(asset.encode('public/style'), 'public/style'); }); it('ignores empty string', () => { assert.equal(asset.encode(''), ''); }); it('ignores null', () => { assert.equal(asset.encode(null), null); }); it('caches results', () => { asset.encode('public/style.css'); asset.encode('public/style.css'); assert.equal(checksumProvider.get.callCount, 1); }); }); describe('decode', () => { it('ignores dirs', () => { assert.equal(asset.decode('public/style'), 'public/style'); }); it('ignores empty string', () => { assert.equal(asset.decode(''), ''); }); it('ignores null', () => { assert.equal(asset.decode(null), null); }); it('ignores non checksums', () => { assert.equal(asset.decode('style.12345/7890.css'), 'style.12345/7890.css'); }); it('removes checksum from file name', () => { assert.equal(asset.decode('public/style.1234567890.css'), 'public/style.css'); }); it('only checksums with length of 10', () => { assert.equal(asset.decode('public/style.123456789.css'), 'public/style.123456789.css'); assert.equal(asset.decode('public/style.12345678900.css'), 'public/style.12345678900.css'); }); }); afterEach(() => { simple.restore(); }); }); ================================================ FILE: test/app/lib/color.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it */ 'use strict'; const assert = require('chai').assert; const color = require('../../../lib/color'); describe('color.js', () => { describe('contrastRatio', () => { it('Calculates correct ratio for #000000', () => { const ratio = color.contrastRatio('#000000'); assert.equal(ratio, 21); }); it('Calculates correct ratio for #000000 / #FFFFFF', () => { const ratio = color.contrastRatio('#000000', '#FFFFFF'); assert.equal(ratio, 21); }); it('Calculates correct ratio for #FFFFFF / #000000', () => { const ratio = color.contrastRatio('#FFFFFF', '#000000'); assert.equal(ratio, 21); }); it('Calculates correct ratio for #FFFFFF / #FFFFFF', () => { const ratio = color.contrastRatio('#FFFFFF', '#FFFFFF'); assert.equal(ratio, 1); }); it('Calculates correct ratio for #FFFFFF / transparent', () => { const ratio = color.contrastRatio('#FFFFFF', 'transparent'); assert.equal(ratio, 1); }); }); describe('bestContrastRatio', () => { it('Selects best contrast between #000000 and #FFFFFF agains #000000', () => { const bestContrast = color.bestContrastRatio('#000000', '#FFFFFF', '#000000'); assert.equal(bestContrast, '#FFFFFF'); }); it('Selects best contrast between #000000 and #FFFFFF agains black', () => { const bestContrast = color.bestContrastRatio('#000000', '#FFFFFF', 'black'); assert.equal(bestContrast, '#FFFFFF'); }); }); describe('relativeLuminance', () => { it('Calculates correct luminance for #FFFFFF', () => { const luminance = color.relativeLuminance('#FFFFFF'); assert.equal(luminance, 1); }); it('Calculates correct luminance for #000000', () => { const luminance = color.relativeLuminance('#000000'); assert.equal(luminance, 0); }); it('Calculates correct luminance for "#000000 "', () => { const luminance = color.relativeLuminance('#000000 '); assert.equal(luminance, 0); }); it('Calculates correct luminance for "black"', () => { const luminance = color.relativeLuminance('black'); assert.equal(luminance, 0); }); }); }); ================================================ FILE: test/app/lib/data-fetcher.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it afterEach*/ 'use strict'; let dataFetcher = require('../../../lib/data-fetcher'); const simpleMock = require('simple-mock'); let chai = require('chai'); let chaiAsPromised = require('chai-as-promised'); const assert = require('chai').assert; chai.use(chaiAsPromised); chai.should(); const LIGHTHOUSE_JSON_EXAMPLE = './test/app/lib/lighthouse-example.json'; describe('lib.data-fetcher', () => { it('fetchMetadataDescription(null) should fail', () => { return dataFetcher.fetchMetadataDescription(null).should.be.rejectedWith(Error); }); it('fetchMetadataDescription(https://www.google.com) should work', () => { return dataFetcher.fetchMetadataDescription('https://www.google.com').should.be.fulfilled; }); it('readfile(LIGHTHOUSE_JSON_EXAMPLE) should work', () => { return dataFetcher.readFile(LIGHTHOUSE_JSON_EXAMPLE).should.be.fulfilled; }); describe('#_firebaseOptions', () => { it('should call with GET method', () => { const options = dataFetcher._firebaseOptions(); assert.equal(options.method, 'GET'); assert(options.headers.Authorization, 'Should contain Authorization header'); }); it('should call with POST method when payload exists', () => { const options = dataFetcher._firebaseOptions({}); assert.equal(options.method, 'POST'); assert(options.headers.Authorization, 'Should contain Authorization header'); assert.equal(options.headers['content-type'], 'application/json', 'Correct content-type'); }); }); describe('#_handleFirebaseResponse', () => { afterEach(() => { simpleMock.restore(); }); it('should succeed when code is 200', () => { const response = {}; simpleMock.mock(response, 'status', 200); simpleMock.mock(response, 'json').resolveWith({}); return dataFetcher._handleFirebaseResponse(response).should.be.fulfilled .then(() => { assert(response.json.called); }); }); it('should reject when code is not 200', () => { const response = {}; simpleMock.mock(response, 'status', 402); simpleMock.mock(response, 'text').resolveWith({}); return dataFetcher._handleFirebaseResponse(response).should.be.rejected .then(() => { assert(response.text.called); }); }); }); }); ================================================ FILE: test/app/lib/favorite-pwa.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it before beforeEach */ 'use strict'; const assert = require('assert'); const config = require('../../../config/config'); const datastore = require('@google-cloud/datastore'); const ds = datastore({ projectId: config.get('GCLOUD_PROJECT') }); const FavoritePwa = require('../../../models/favorite-pwa'); const libFavoritePwa = require('../../../lib/favorite-pwa'); const ENTITY_NAME = 'FAVORITE-PWA'; const TEST_FAV_PWA = new FavoritePwa(123456789, 987654321); describe('lib.favorite-pwa', () => { const skipTests = process.env.TRAVIS; // Skip tests if Running in CI before(function() { this.timeout(3000); if (skipTests) { this.skip(); return; } // Deletes all entities on the 'test' namespace before each test. return new Promise((resolve, reject) => { const q = ds.createQuery(ENTITY_NAME).filter('pwaId', '=', parseInt(TEST_FAV_PWA.pwaId, 10)); ds.runQuery(q, (err, entities) => { if (err) { return reject(err); } const keys = entities.map(entity => { return entity.key; }); // Delete counts for 'test'. keys[keys.length] = ds.key(['counts', ENTITY_NAME]); ds.delete(keys, err => { return reject(err); }); return resolve(); }); }); }); describe('#save and find', () => { beforeEach(function() { if (skipTests) { this.skip(); return; } }); let savedFavoritePwa; before(() => { if (skipTests) { return; } return libFavoritePwa.save(TEST_FAV_PWA) .then(saved => { savedFavoritePwa = saved; }); }); it('save', () => { assert.equal(savedFavoritePwa.pwaId, TEST_FAV_PWA.pwaId); assert.equal(savedFavoritePwa.userId, TEST_FAV_PWA.userId); }); it('findByUserId', () => { return libFavoritePwa.findByUserId(TEST_FAV_PWA.userId) .then(foundFavoritePwas => { assert.equal(foundFavoritePwas[0].pwaId, TEST_FAV_PWA.pwaId); assert.equal(foundFavoritePwas[0].userId, TEST_FAV_PWA.userId); }); }); it('findFavoritePwa', () => { return libFavoritePwa.findFavoritePwa(TEST_FAV_PWA.pwaId, TEST_FAV_PWA.userId) .then(foundFavoritePwa => { assert.equal(foundFavoritePwa.pwaId, TEST_FAV_PWA.pwaId); assert.equal(foundFavoritePwa.userId, TEST_FAV_PWA.userId); }); }); }); describe('#delete', () => { beforeEach(function() { if (skipTests) { this.skip(); return; } }); it('delete', () => { return libFavoritePwa.save(TEST_FAV_PWA) .then(saved => { return libFavoritePwa.delete(saved.id).should.be.fulfilled; }); }); }); }); ================================================ FILE: test/app/lib/images.js ================================================ /** * Copyright 2015-2016, Google, Inc. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* global describe it afterEach before */ 'use strict'; const libImages = require('../../../lib/images'); const dataFetcher = require('../../../lib/data-fetcher'); const Manifest = require('../../../models/manifest'); const httpMocks = require('node-mocks-http'); const simpleMock = require('simple-mock'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); chai.should(); const assert = require('chai').assert; const MANIFEST_URL = 'https://mobile.twitter.com/manifest.json'; const MANIFEST_DATA = './test/app/manifests/inline-image-large-content.json'; describe('lib.images', () => { let manifest; before(done => { dataFetcher.readFile(MANIFEST_DATA) .then(jsonString => { manifest = new Manifest(MANIFEST_URL, JSON.parse(jsonString)); done(); }); }); afterEach(() => { simpleMock.restore(); }); it('fetchAndSave fail fetch for HTTP error', () => { const response = {}; response.status = 400; simpleMock.mock(dataFetcher, 'fetchWithUA').resolveWith(response); simpleMock.mock(libImages, 'saveImage').resolveWith(null); return libImages.fetchAndSave('http://www.test.com', null).should.be.rejectedWith( 'Bad Response (400) loading image: undefined'); }); it('fetchAndSave fail for unsoported protocol (ftp:)', () => { const response = {}; response.status = 400; simpleMock.mock(dataFetcher, 'fetchWithUA').resolveWith(response); simpleMock.mock(libImages, 'saveImage').resolveWith(null); return libImages.fetchAndSave('ftp://www.test.com', null).should.be.rejectedWith( 'Unsupported Protocol: ftp:'); }); it('fetchAndSave works with http url', () => { const headers = {}; simpleMock.mock(headers, 'get').returnWith('image/jpeg'); const response = httpMocks.createResponse(); response.headers = headers; response.status = 200; response.body = ''; simpleMock.mock(dataFetcher, 'fetchWithUA').resolveWith(response); simpleMock.mock(libImages, 'saveImage').resolveWith('http://url.for.newimage.in.bucket.com'); return libImages.fetchAndSave('http://www.test.com', 'destFile').should.be.fulfilled.then(_ => { assert.equal(libImages.saveImage.callCount, 3); }); }); it('fetchAndSave works with https url', () => { const response = httpMocks.createResponse(); const headers = {}; simpleMock.mock(headers, 'get').returnWith('image/jpeg'); response.headers = headers; response.status = 200; response.body = ''; simpleMock.mock(dataFetcher, 'fetchWithUA').resolveWith(response); simpleMock.mock(libImages, 'saveImage').resolveWith('http://url.for.newimage.in.bucket.com'); return libImages.fetchAndSave('https://www.test.com', 'destFile').should.be.fulfilled.then(_ => { assert.equal(libImages.saveImage.callCount, 3); }); }); it('dataUriAndSave data uri', () => { const bestIconUrl = manifest.getBestIconUrl(); simpleMock.mock(libImages, 'saveImage').resolveWith('http://url.for.newimage.in.bucket.com'); return libImages.dataUriAndSave(bestIconUrl).should.be.fulfilled.then(_ => { assert.equal(libImages.saveImage.callCount, 3); }); }); }); ================================================ FILE: test/app/lib/lighthouse-example.json ================================================ [ { "id": "5636139285217280-20180307", "webPageUrlId": "5636139285217280", "url": "https://pwa-directory.appspot.com/", "date": "20180307", "score": 90, "pwaScore": 90.91, "pwaWeight": 1, "performanceScore": 41.12, "performanceWeight": 0, "accessibilityScore": 54.55, "accessibilityWeight": 0, "bestPracticesScore": 100, "bestPracticesWeight": 0, "rawData": { "value": "{\"userAgent\":\"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.167 Safari/537.36\",\"lighthouseVersion\":\"2.9.1\",\"generatedTime\":\"2018-03-07T09:58:34.829Z\",\"initialUrl\":\"https://pwa-directory.appspot.com/\",\"url\":\"https://pwa-directory.appspot.com/\",\"runWarnings\":[],\"audits\":{\"is-on-https\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"extendedInfo\":{\"value\":[]},\"scoringMode\":\"binary\",\"name\":\"is-on-https\",\"description\":\"Uses HTTPS\",\"helpText\":\"All sites should be protected with HTTPS, even ones that don't handle sensitive data. HTTPS prevents intruders from tampering with or passively listening in on the communications between your app and your users, and is a prerequisite for HTTP/2 and many new web platform APIs. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/https).\",\"details\":{\"type\":\"list\",\"header\":{\"type\":\"text\",\"text\":\"Insecure URLs:\"},\"items\":[]}},\"redirects-http\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"redirects-http\",\"description\":\"Redirects HTTP traffic to HTTPS\",\"helpText\":\"If you've already set up HTTPS, make sure that you redirect all HTTP traffic to HTTPS. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/http-redirects-to-https).\"},\"service-worker\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"service-worker\",\"description\":\"Registers a service worker\",\"helpText\":\"The service worker is the technology that enables your app to use many Progressive Web App features, such as offline, add to homescreen, and push notifications. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/registered-service-worker).\"},\"works-offline\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"works-offline\",\"description\":\"Responds with a 200 when offline\",\"helpText\":\"If you're building a Progressive Web App, consider using a service worker so that your app can work offline. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/http-200-when-offline).\"},\"viewport\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"debugString\":\"\",\"scoringMode\":\"binary\",\"name\":\"viewport\",\"description\":\"Has a `` tag with `width` or `initial-scale`\",\"helpText\":\"Add a viewport meta tag to optimize your app for mobile screens. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/has-viewport-meta-tag).\"},\"without-javascript\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"without-javascript\",\"description\":\"Contains some content when JavaScript is not available\",\"helpText\":\"Your app should display some content when JavaScript is disabled, even if it's just a warning to the user that JavaScript is required to use the app. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/no-js).\"},\"first-meaningful-paint\":{\"score\":100,\"displayValue\":\"1,040 ms\",\"rawValue\":1037.2,\"extendedInfo\":{\"value\":{\"timestamps\":{\"navStart\":2152463602,\"fCP\":2153500775,\"fMP\":2153500778,\"onLoad\":2156528062,\"endOfTrace\":2161852046},\"timings\":{\"navStart\":0,\"fCP\":1037.173,\"fMP\":1037.176,\"onLoad\":4064.46,\"endOfTrace\":9388.444},\"fmpFellBack\":false}},\"scoringMode\":\"numeric\",\"name\":\"first-meaningful-paint\",\"description\":\"First meaningful paint\",\"helpText\":\"First meaningful paint measures when the primary content of a page is visible. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint).\"},\"load-fast-enough-for-pwa\":{\"score\":null,\"displayValue\":\"\",\"rawValue\":null,\"error\":true,\"debugString\":\"Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse. (NO_FCPUI_IDLE_PERIOD)\",\"scoringMode\":\"binary\",\"name\":\"load-fast-enough-for-pwa\",\"description\":\"Page load is not fast enough on 3G\",\"helpText\":\"A fast page load over a 3G network ensures a good mobile user experience. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/fast-3g).\"},\"speed-index-metric\":{\"score\":99,\"displayValue\":\"1,142\",\"rawValue\":1142,\"extendedInfo\":{\"value\":{\"timings\":{\"firstVisualChange\":1037,\"visuallyReady\":1037.3799999998882,\"visuallyComplete\":2898,\"perceptualSpeedIndex\":1141.835581201553},\"timestamps\":{\"firstVisualChange\":2153500602,\"visuallyReady\":2153500982,\"visuallyComplete\":2155361602,\"perceptualSpeedIndex\":2153605437.5812016},\"frames\":[{\"timestamp\":2152463.602,\"progress\":0},{\"timestamp\":2153500.982,\"progress\":90.31905946956357},{\"timestamp\":2153832.606,\"progress\":90.31905946956357},{\"timestamp\":2153849.478,\"progress\":90.33605036713934},{\"timestamp\":2153870.388,\"progress\":90.33605036713934},{\"timestamp\":2153883.379,\"progress\":90.33605036713934},{\"timestamp\":2153921.808,\"progress\":90.33605036713934},{\"timestamp\":2153932.306,\"progress\":92.35056734420138},{\"timestamp\":2153983.395,\"progress\":92.35056734420138},{\"timestamp\":2153999.242,\"progress\":92.35056734420138},{\"timestamp\":2154046.561,\"progress\":92.35056734420138},{\"timestamp\":2154066.125,\"progress\":95.22683766928108},{\"timestamp\":2154082.415,\"progress\":95.22683766928108},{\"timestamp\":2154118.314,\"progress\":95.23115222717236},{\"timestamp\":2154962.536,\"progress\":97.56635937215954},{\"timestamp\":2155362.197,\"progress\":100}]}},\"scoringMode\":\"numeric\",\"name\":\"speed-index-metric\",\"description\":\"Perceptual Speed Index\",\"helpText\":\"Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/speed-index).\"},\"screenshot-thumbnails\":{\"score\":100,\"displayValue\":\"true\",\"rawValue\":true,\"scoringMode\":\"binary\",\"informative\":true,\"name\":\"screenshot-thumbnails\",\"description\":\"Screenshot Thumbnails\",\"helpText\":\"This is what the load of your site looked like.\",\"details\":{\"type\":\"filmstrip\",\"scale\":2898,\"items\":[{\"timing\":290,\"timestamp\":2152753402,\"data\":\"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIANUAeAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD//Z\"},{\"timing\":580,\"timestamp\":2153043202,\"data\":\"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIANUAeAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD//Z\"},{\"timing\":869,\"timestamp\":2153333002,\"data\":\"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIANUAeAMBEQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AP1ToAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgAoAKACgD//Z\"},{\"timing\":1159,\"timestamp\":2153622802,\"data\":\"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\"},{\"timing\":1449,\"timestamp\":2153912602,\"data\":\"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\"},{\"timing\":1739,\"timestamp\":2154202402,\"data\":\"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\"},{\"timing\":2029,\"timestamp\":2154492202,\"data\":\"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\"},{\"timing\":2318,\"timestamp\":2154782002,\"data\":\"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\"},{\"timing\":2608,\"timestamp\":2155071802,\"data\":\"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\"},{\"timing\":2898,\"timestamp\":2155361602,\"data\":\"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\"}]}},\"estimated-input-latency\":{\"score\":100,\"displayValue\":\"21 ms\",\"rawValue\":20.8,\"extendedInfo\":{\"value\":[{\"percentile\":0.5,\"time\":16},{\"percentile\":0.75,\"time\":16},{\"percentile\":0.9,\"time\":20.799407692307533},{\"percentile\":0.99,\"time\":90.00607999999784},{\"percentile\":1,\"time\":128.2549999999901}]},\"scoringMode\":\"numeric\",\"name\":\"estimated-input-latency\",\"description\":\"Estimated Input Latency\",\"helpText\":\"The score above is an estimate of how long your app takes to respond to user input, in milliseconds. There is a 90% probability that a user encounters this amount of latency, or less. 10% of the time a user can expect additional latency. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/estimated-input-latency).\"},\"errors-in-console\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":0,\"scoringMode\":\"binary\",\"name\":\"errors-in-console\",\"description\":\"No browser errors logged to the console\",\"helpText\":\"Errors logged to the console indicate unresolved problems. They can come from network request failures and other browser concerns.\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"URL\"},{\"type\":\"text\",\"itemType\":\"code\",\"text\":\"Description\"}],\"items\":[]}},\"time-to-first-byte\":{\"score\":true,\"displayValue\":\"560 ms\",\"rawValue\":564.1660000001137,\"debugString\":\"\",\"extendedInfo\":{\"value\":{\"wastedMs\":-35.83399999988626}},\"scoringMode\":\"binary\",\"informative\":true,\"name\":\"time-to-first-byte\",\"description\":\"Keep server response times low (TTFB)\",\"helpText\":\"Time To First Byte identifies the time at which your server sends a response. [Learn more](https://developers.google.com/web/tools/chrome-devtools/network-performance/issues).\"},\"first-interactive\":{\"score\":null,\"displayValue\":\"\",\"rawValue\":null,\"error\":true,\"debugString\":\"Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse. (NO_FCPUI_IDLE_PERIOD)\",\"scoringMode\":\"numeric\",\"name\":\"first-interactive\",\"description\":\"First Interactive (beta)\",\"helpText\":\"First Interactive marks the time at which the page is minimally interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/first-interactive).\"},\"consistently-interactive\":{\"score\":null,\"displayValue\":\"\",\"rawValue\":null,\"error\":true,\"debugString\":\"Your page took too long to load. Please follow the opportunities in the report to reduce your page load time, and then try re-running Lighthouse. (NO_TTI_CPU_IDLE_PERIOD)\",\"scoringMode\":\"numeric\",\"name\":\"consistently-interactive\",\"description\":\"Consistently Interactive (beta)\",\"helpText\":\"Consistently Interactive marks the time at which the page is fully interactive. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/consistently-interactive).\"},\"user-timings\":{\"score\":true,\"displayValue\":\"0\",\"rawValue\":true,\"extendedInfo\":{\"value\":[]},\"scoringMode\":\"binary\",\"informative\":true,\"name\":\"user-timings\",\"description\":\"User Timing marks and measures\",\"helpText\":\"Consider instrumenting your app with the User Timing API to create custom, real-world measurements of key user experiences. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/user-timing).\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Name\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Type\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Time\"}],\"items\":[]}},\"critical-request-chains\":{\"score\":true,\"displayValue\":\"0\",\"rawValue\":true,\"extendedInfo\":{\"value\":{\"chains\":{\"2047.1\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/\",\"startTime\":2152.467431,\"endTime\":2153.09461,\"responseReceivedTime\":2153.061376,\"transferSize\":6367},\"children\":{}},\"2047.2\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/css/style.73cd99ab03.css\",\"startTime\":2153.127787,\"endTime\":2153.236907,\"responseReceivedTime\":2153.188976,\"transferSize\":4740},\"children\":{}},\"2047.3\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/js/gulliver.cd85edbda4.js\",\"startTime\":2153.128558,\"endTime\":2153.473274,\"responseReceivedTime\":2153.292108,\"transferSize\":20538},\"children\":{}}},\"longestChain\":{\"duration\":1005.8429999999134,\"length\":1,\"transferSize\":20538}}},\"scoringMode\":\"binary\",\"informative\":true,\"name\":\"critical-request-chains\",\"description\":\"Critical Request Chains\",\"helpText\":\"The Critical Request Chains below show you what resources are issued with a high priority. Consider reducing the length of chains, reducing the download size of resources, or deferring the download of unnecessary resources to improve page load. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/critical-request-chains).\",\"details\":{\"type\":\"criticalrequestchain\",\"header\":{\"type\":\"text\",\"text\":\"View critical network waterfall:\"},\"chains\":{\"2047.1\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/\",\"startTime\":2152.467431,\"endTime\":2153.09461,\"responseReceivedTime\":2153.061376,\"transferSize\":6367},\"children\":{}},\"2047.2\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/css/style.73cd99ab03.css\",\"startTime\":2153.127787,\"endTime\":2153.236907,\"responseReceivedTime\":2153.188976,\"transferSize\":4740},\"children\":{}},\"2047.3\":{\"request\":{\"url\":\"https://pwa-directory.appspot.com/js/gulliver.cd85edbda4.js\",\"startTime\":2153.128558,\"endTime\":2153.473274,\"responseReceivedTime\":2153.292108,\"transferSize\":20538},\"children\":{}}},\"longestChain\":{\"duration\":1005.8429999999134,\"length\":1,\"transferSize\":20538}}},\"redirects\":{\"score\":100,\"displayValue\":\"0 ms\",\"rawValue\":0,\"extendedInfo\":{\"value\":{\"wastedMs\":0}},\"scoringMode\":\"binary\",\"name\":\"redirects\",\"description\":\"Avoids page redirects\",\"helpText\":\"Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/speed/docs/insights/AvoidRedirects).\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Redirected URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Time for Redirect\"}],\"items\":[]}},\"webapp-install-banner\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"extendedInfo\":{\"value\":{\"warnings\":[],\"failures\":[],\"manifestValues\":{\"isParseFailure\":false,\"allChecks\":[{\"id\":\"hasStartUrl\",\"failureText\":\"Manifest does not contain a `start_url`\",\"passing\":true},{\"id\":\"hasIconsAtLeast192px\",\"failureText\":\"Manifest does not have icons at least 192px\",\"passing\":true},{\"id\":\"hasIconsAtLeast512px\",\"failureText\":\"Manifest does not have icons at least 512px\",\"passing\":true},{\"id\":\"hasPWADisplayValue\",\"failureText\":\"Manifest's `display` value is not one of: minimal-ui | fullscreen | standalone\",\"passing\":true},{\"id\":\"hasBackgroundColor\",\"failureText\":\"Manifest does not have `background_color`\",\"passing\":true},{\"id\":\"hasThemeColor\",\"failureText\":\"Manifest does not have `theme_color`\",\"passing\":true},{\"id\":\"hasShortName\",\"failureText\":\"Manifest does not have `short_name`\",\"passing\":true},{\"id\":\"shortNameLength\",\"failureText\":\"Manifest `short_name` will be truncated when displayed on the homescreen\",\"passing\":true},{\"id\":\"hasName\",\"failureText\":\"Manifest does not have `name`\",\"passing\":true}]}}},\"scoringMode\":\"binary\",\"name\":\"webapp-install-banner\",\"description\":\"User can be prompted to Install the Web App\",\"helpText\":\"Browsers can proactively prompt users to add your app to their homescreen, which can lead to higher engagement. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/install-prompt).\"},\"splash-screen\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"extendedInfo\":{\"value\":{\"failures\":[],\"manifestValues\":{\"isParseFailure\":false,\"allChecks\":[{\"id\":\"hasStartUrl\",\"failureText\":\"Manifest does not contain a `start_url`\",\"passing\":true},{\"id\":\"hasIconsAtLeast192px\",\"failureText\":\"Manifest does not have icons at least 192px\",\"passing\":true},{\"id\":\"hasIconsAtLeast512px\",\"failureText\":\"Manifest does not have icons at least 512px\",\"passing\":true},{\"id\":\"hasPWADisplayValue\",\"failureText\":\"Manifest's `display` value is not one of: minimal-ui | fullscreen | standalone\",\"passing\":true},{\"id\":\"hasBackgroundColor\",\"failureText\":\"Manifest does not have `background_color`\",\"passing\":true},{\"id\":\"hasThemeColor\",\"failureText\":\"Manifest does not have `theme_color`\",\"passing\":true},{\"id\":\"hasShortName\",\"failureText\":\"Manifest does not have `short_name`\",\"passing\":true},{\"id\":\"shortNameLength\",\"failureText\":\"Manifest `short_name` will be truncated when displayed on the homescreen\",\"passing\":true},{\"id\":\"hasName\",\"failureText\":\"Manifest does not have `name`\",\"passing\":true}]}}},\"scoringMode\":\"binary\",\"name\":\"splash-screen\",\"description\":\"Configured for a custom splash screen\",\"helpText\":\"A themed splash screen ensures a high-quality experience when users launch your app from their homescreens. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/custom-splash-screen).\"},\"themed-omnibox\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"extendedInfo\":{\"value\":{\"failures\":[],\"manifestValues\":{\"isParseFailure\":false,\"allChecks\":[{\"id\":\"hasStartUrl\",\"failureText\":\"Manifest does not contain a `start_url`\",\"passing\":true},{\"id\":\"hasIconsAtLeast192px\",\"failureText\":\"Manifest does not have icons at least 192px\",\"passing\":true},{\"id\":\"hasIconsAtLeast512px\",\"failureText\":\"Manifest does not have icons at least 512px\",\"passing\":true},{\"id\":\"hasPWADisplayValue\",\"failureText\":\"Manifest's `display` value is not one of: minimal-ui | fullscreen | standalone\",\"passing\":true},{\"id\":\"hasBackgroundColor\",\"failureText\":\"Manifest does not have `background_color`\",\"passing\":true},{\"id\":\"hasThemeColor\",\"failureText\":\"Manifest does not have `theme_color`\",\"passing\":true},{\"id\":\"hasShortName\",\"failureText\":\"Manifest does not have `short_name`\",\"passing\":true},{\"id\":\"shortNameLength\",\"failureText\":\"Manifest `short_name` will be truncated when displayed on the homescreen\",\"passing\":true},{\"id\":\"hasName\",\"failureText\":\"Manifest does not have `name`\",\"passing\":true}]},\"themeColor\":\"#7cc0ff\"}},\"scoringMode\":\"binary\",\"name\":\"themed-omnibox\",\"description\":\"Address bar matches brand colors\",\"helpText\":\"The browser address bar can be themed to match your site. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/address-bar).\"},\"manifest-short-name-length\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"manifest-short-name-length\",\"description\":\"Manifest's `short_name` won't be truncated when displayed on homescreen\",\"helpText\":\"Make your app's `short_name` fewer than 12 characters to ensure that it's not truncated on homescreens. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/manifest-short_name-is-not-truncated).\"},\"content-width\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"debugString\":\"\",\"scoringMode\":\"binary\",\"name\":\"content-width\",\"description\":\"Content is sized correctly for the viewport\",\"helpText\":\"If the width of your app's content doesn't match the width of the viewport, your app might not be optimized for mobile screens. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/content-sized-correctly-for-viewport).\"},\"image-aspect-ratio\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"image-aspect-ratio\",\"description\":\"Displays images with correct aspect ratio\",\"helpText\":\"Image display dimensions should match natural aspect ratio.\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"thumbnail\",\"text\":\"\"},{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Aspect Ratio (Displayed)\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Aspect Ratio (Actual)\"}],\"items\":[]}},\"deprecations\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"extendedInfo\":{\"value\":[]},\"scoringMode\":\"binary\",\"name\":\"deprecations\",\"description\":\"Avoids deprecated APIs\",\"helpText\":\"Deprecated APIs will eventually be removed from the browser. [Learn more](https://www.chromestatus.com/features#deprecated).\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"code\",\"text\":\"Deprecation / Warning\"},{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Line\"}],\"items\":[]}},\"mainthread-work-breakdown\":{\"score\":true,\"displayValue\":\"1,050 ms\",\"rawValue\":1046.6150000002235,\"extendedInfo\":{\"value\":{\"Evaluate Script\":303.1429999987595,\"DOM GC\":203.60300000058487,\"Layout\":169.78400000045076,\"Parse HTML\":65.91100000031292,\"Recalculate Style\":58.389999999664724,\"Paint\":54.391999998129904,\"Minor GC\":37.812999999616295,\"Composite Layers\":30.600000000093132,\"Run Microtasks\":30.269000000786036,\"Major GC\":30.259999999776483,\"Compile Script\":25.316000001039356,\"Update Layer Tree\":19.307000000029802,\"Parse Stylesheet\":10.522000000346452,\"XHR Ready State Change\":7.12000000057742,\"Image Decode\":0.1790000000037253,\"XHR Load\":0.006000000052154064}},\"scoringMode\":\"binary\",\"informative\":true,\"name\":\"mainthread-work-breakdown\",\"description\":\"Main thread work breakdown\",\"helpText\":\"Consider reducing the time spent parsing, compiling and executing JS.You may find delivering smaller JS payloads helps with this.\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Category\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Work\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Time spent\"}],\"items\":[[{\"type\":\"text\",\"text\":\"Script Evaluation\"},{\"type\":\"text\",\"text\":\"Evaluate Script\"},{\"type\":\"text\",\"text\":\"303 ms\"}],[{\"type\":\"text\",\"text\":\"Script Evaluation\"},{\"type\":\"text\",\"text\":\"Run Microtasks\"},{\"type\":\"text\",\"text\":\"30 ms\"}],[{\"type\":\"text\",\"text\":\"Script Evaluation\"},{\"type\":\"text\",\"text\":\"XHR Ready State Change\"},{\"type\":\"text\",\"text\":\"7 ms\"}],[{\"type\":\"text\",\"text\":\"Script Evaluation\"},{\"type\":\"text\",\"text\":\"XHR Load\"},{\"type\":\"text\",\"text\":\"0 ms\"}],[{\"type\":\"text\",\"text\":\"Garbage collection\"},{\"type\":\"text\",\"text\":\"DOM GC\"},{\"type\":\"text\",\"text\":\"204 ms\"}],[{\"type\":\"text\",\"text\":\"Garbage collection\"},{\"type\":\"text\",\"text\":\"Minor GC\"},{\"type\":\"text\",\"text\":\"38 ms\"}],[{\"type\":\"text\",\"text\":\"Garbage collection\"},{\"type\":\"text\",\"text\":\"Major GC\"},{\"type\":\"text\",\"text\":\"30 ms\"}],[{\"type\":\"text\",\"text\":\"Style & Layout\"},{\"type\":\"text\",\"text\":\"Layout\"},{\"type\":\"text\",\"text\":\"170 ms\"}],[{\"type\":\"text\",\"text\":\"Style & Layout\"},{\"type\":\"text\",\"text\":\"Recalculate Style\"},{\"type\":\"text\",\"text\":\"58 ms\"}],[{\"type\":\"text\",\"text\":\"Parsing HTML & CSS\"},{\"type\":\"text\",\"text\":\"Parse HTML\"},{\"type\":\"text\",\"text\":\"66 ms\"}],[{\"type\":\"text\",\"text\":\"Parsing HTML & CSS\"},{\"type\":\"text\",\"text\":\"Parse Stylesheet\"},{\"type\":\"text\",\"text\":\"11 ms\"}],[{\"type\":\"text\",\"text\":\"Paint\"},{\"type\":\"text\",\"text\":\"Paint\"},{\"type\":\"text\",\"text\":\"54 ms\"}],[{\"type\":\"text\",\"text\":\"Compositing\"},{\"type\":\"text\",\"text\":\"Composite Layers\"},{\"type\":\"text\",\"text\":\"31 ms\"}],[{\"type\":\"text\",\"text\":\"Compositing\"},{\"type\":\"text\",\"text\":\"Update Layer Tree\"},{\"type\":\"text\",\"text\":\"19 ms\"}],[{\"type\":\"text\",\"text\":\"Script Parsing & Compile\"},{\"type\":\"text\",\"text\":\"Compile Script\"},{\"type\":\"text\",\"text\":\"25 ms\"}],[{\"type\":\"text\",\"text\":\"Images\"},{\"type\":\"text\",\"text\":\"Image Decode\"},{\"type\":\"text\",\"text\":\"0 ms\"}]]}},\"bootup-time\":{\"score\":true,\"displayValue\":\"400 ms\",\"rawValue\":398.2899999995716,\"extendedInfo\":{\"value\":{\"https://www.google-analytics.com/analytics.js\":{\"Script Evaluation\":88.33199999993667,\"Script Parsing & Compile\":5.565000000409782},\"https://pwa-directory.appspot.com/js/gulliver.cd85edbda4.js\":{\"Script Evaluation\":80.06300000008196,\"Script Parsing & Compile\":4.246999999973923,\"Style & Layout\":2.6689999997615814},\"https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en_US.dLR0UQgpDEo.O/m=auth2/rt=j/sv=1/d=1/ed=1/am=AQE/rs=AGLTcCMdWrzahDTQIubih7dySWJqcBU_nw/cb=gapi.loaded_0\":{\"Script Evaluation\":73.78999999910593,\"Script Parsing & Compile\":3.5409999997355044,\"Style & Layout\":1.125},\"https://pwa-directory.appspot.com/\":{\"Parsing HTML & CSS\":56.77900000009686},\"https://accounts.google.com/o/oauth2/iframe\":{\"Script Evaluation\":29.386999999638647,\"Script Parsing & Compile\":0.12700000032782555},\"https://apis.google.com/js/api.js?onload=gapiResolve\":{\"Script Evaluation\":19.56899999966845,\"Script Parsing & Compile\":6.076000000350177,\"Style & Layout\":0.04199999989941716},\"https://ssl.gstatic.com/accounts/o/2818585737-idpiframe.js\":{\"Script Evaluation\":6.666000000201166,\"Script Parsing & Compile\":5.13799999980256},\"https://accounts.google.com/o/oauth2/iframe#origin=https%3A%2F%2Fpwa-directory.appspot.com&rpcToken=401330920.1489836&clearCache=1\":{\"Parsing HTML & CSS\":7.239000000059605},\"https://accounts.google.com/o/oauth2/iframerpc?action=checkOrigin&origin=https%3A%2F%2Fpwa-directory.appspot.com&client_id=896499748108-ru4bhfh743clb3v3dc8bgf1v68umkvcu.apps.googleusercontent.com\":{\"Script Evaluation\":7.126000000629574},\"https://pwa-directory.appspot.com/sw.js\":{\"Parsing HTML & CSS\":0.8089999998919666}}},\"scoringMode\":\"binary\",\"name\":\"bootup-time\",\"description\":\"JavaScript boot-up time\",\"helpText\":\"Consider reducing the time spent parsing, compiling and executing JS. You may find delivering smaller JS payloads helps with this.\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Script Evaluation\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Script Parsing & Compile\"}],\"items\":[[{\"type\":\"url\",\"text\":\"https://www.google-analytics.com/analytics.js\"},{\"type\":\"text\",\"text\":\"88 ms\"},{\"type\":\"text\",\"text\":\"6 ms\"}],[{\"type\":\"url\",\"text\":\"https://pwa-directory.appspot.com/js/gulliver.cd85edbda4.js\"},{\"type\":\"text\",\"text\":\"80 ms\"},{\"type\":\"text\",\"text\":\"4 ms\"}],[{\"type\":\"url\",\"text\":\"https://apis.google.com/_/scs/apps-static/_/js/k=oz.gapi.en_US.dLR0UQgpDEo.O/m=auth2/rt=j/sv=1/d=1/ed=1/am=AQE/rs=AGLTcCMdWrzahDTQIubih7dySWJqcBU_nw/cb=gapi.loaded_0\"},{\"type\":\"text\",\"text\":\"74 ms\"},{\"type\":\"text\",\"text\":\"4 ms\"}],[{\"type\":\"url\",\"text\":\"https://accounts.google.com/o/oauth2/iframe\"},{\"type\":\"text\",\"text\":\"29 ms\"},{\"type\":\"text\",\"text\":\"0 ms\"}],[{\"type\":\"url\",\"text\":\"https://apis.google.com/js/api.js?onload=gapiResolve\"},{\"type\":\"text\",\"text\":\"20 ms\"},{\"type\":\"text\",\"text\":\"6 ms\"}],[{\"type\":\"url\",\"text\":\"https://ssl.gstatic.com/accounts/o/2818585737-idpiframe.js\"},{\"type\":\"text\",\"text\":\"7 ms\"},{\"type\":\"text\",\"text\":\"5 ms\"}]]}},\"uses-rel-preload\":{\"score\":100,\"displayValue\":\"0 ms\",\"rawValue\":0,\"extendedInfo\":{\"value\":[]},\"scoringMode\":\"numeric\",\"informative\":true,\"name\":\"uses-rel-preload\",\"description\":\"Preload key requests\",\"helpText\":\"Consider using to prioritize fetching late-discovered resources sooner [Learn more](https://developers.google.com/web/updates/2016/03/link-rel-preload).\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Potential Savings\"}],\"items\":[]}},\"font-display\":{\"score\":true,\"displayValue\":\"\",\"rawValue\":true,\"scoringMode\":\"binary\",\"name\":\"font-display\",\"description\":\"All text remains visible during webfont loads\",\"helpText\":\"Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://developers.google.com/web/updates/2016/02/font-display).\",\"details\":{\"type\":\"table\",\"header\":\"View Details\",\"itemHeaders\":[{\"type\":\"text\",\"itemType\":\"url\",\"text\":\"Font URL\"},{\"type\":\"text\",\"itemType\":\"text\",\"text\":\"Font download time\"}],\"items\":[]}},\"pwa-cross-browser\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"manual\":true,\"name\":\"pwa-cross-browser\",\"description\":\"Site works cross-browser\",\"helpText\":\"To reach the most number of users, sites should work across every major browser. [Learn more](https://developers.google.com/web/progressive-web-apps/checklist#site-works-cross-browser).\"},\"pwa-page-transitions\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"manual\":true,\"name\":\"pwa-page-transitions\",\"description\":\"Page transitions don't feel like they block on the network\",\"helpText\":\"Transitions should feel snappy as you tap around, even on a slow network, a key to perceived performance. [Learn more](https://developers.google.com/web/progressive-web-apps/checklist#page-transitions-dont-feel-like-they-block-on-the-network).\"},\"pwa-each-page-has-url\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"manual\":true,\"name\":\"pwa-each-page-has-url\",\"description\":\"Each page has a URL\",\"helpText\":\"Ensure individual pages are deep linkable via the URLs and that URLs are unique for the purpose of shareability on social media. [Learn more](https://developers.google.com/web/progressive-web-apps/checklist#each-page-has-a-url).\"},\"accesskeys\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"accesskeys\",\"description\":\"`[accesskey]` values are not unique\",\"helpText\":\"Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/2.2/accesskeys?application=lighthouse).\"},\"aria-allowed-attr\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-allowed-attr\",\"description\":\"`[aria-*]` attributes do not match their roles\",\"helpText\":\"Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-allowed-attr?application=lighthouse).\"},\"aria-required-attr\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-required-attr\",\"description\":\"`[role]`s do not have all required `[aria-*]` attributes\",\"helpText\":\"Some ARIA roles have required attributes that describe the state of the element to screen readers. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-attr?application=lighthouse).\"},\"aria-required-children\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-required-children\",\"description\":\"Elements with `[role]` that require specific children `[role]`s, are missing.\",\"helpText\":\"Some ARIA parent roles must contain specific child roles to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-children?application=lighthouse).\"},\"aria-required-parent\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-required-parent\",\"description\":\"`[role]`s are not contained by their required parent element\",\"helpText\":\"Some ARIA child roles must be contained by specific parent roles to properly perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-required-parent?application=lighthouse).\"},\"aria-roles\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-roles\",\"description\":\"`[role]` values are not valid\",\"helpText\":\"ARIA roles must have valid values in order to perform their intended accessibility functions. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-roles?application=lighthouse).\"},\"aria-valid-attr-value\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-valid-attr-value\",\"description\":\"`[aria-*]` attributes do not have valid values\",\"helpText\":\"Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid values. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr-value?application=lighthouse).\"},\"aria-valid-attr\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"aria-valid-attr\",\"description\":\"`[aria-*]` attributes are not valid or misspelled\",\"helpText\":\"Assistive technologies, like screen readers, can't interpret ARIA attributes with invalid names. [Learn more](https://dequeuniversity.com/rules/axe/2.2/aria-valid-attr?application=lighthouse).\"},\"audio-caption\":{\"score\":false,\"displayValue\":\"\",\"rawValue\":false,\"scoringMode\":\"binary\",\"informative\":true,\"notApplicable\":true,\"name\":\"audio-caption\",\"description\":\"`