├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .funcignore ├── .github └── workflows │ ├── dev-to-dev.yml │ ├── master-to-prod.yml │ └── playwright.yml ├── .gitignore ├── .nvmrc ├── .openAPI ├── cli │ ├── cli.js │ ├── config.json │ ├── package.json │ ├── reference-specification.json │ └── utils.js ├── components.yaml ├── endpoints.md ├── manifest.yaml ├── open-api.yaml ├── swagger-node-middleware.js └── validation.yaml ├── .prettierrc.json ├── .puppeteerrc.cjs ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── AuditServiceWorker ├── function.json └── index.ts ├── Dockerfile ├── FetchWebManifest ├── function.json └── index.ts ├── FindServiceWorker ├── function.json └── index.ts ├── FindWebManifest ├── function.json └── index.ts ├── GenerateManifest ├── function.json ├── index.ts └── sample.dat ├── Offline ├── function.json ├── index.ts └── sample.dat ├── README.md ├── Report ├── function.json ├── index.ts ├── lighthouse │ ├── custom-audits │ │ ├── develop.md │ │ ├── https │ │ │ └── https-audit.ts │ │ ├── offline │ │ │ ├── offline-audit.ts │ │ │ └── offline-gatherer.ts │ │ ├── service-worker │ │ │ ├── service-worker-audit.ts │ │ │ ├── service-worker-driver.ts │ │ │ └── service-worker-gatherer.ts │ │ └── types.ts │ ├── custom-config.ts │ ├── custom-settings.ts │ └── lighthouse.ts └── type.ts ├── Security ├── function.json ├── index.ts └── sample.dat ├── Site ├── function.json └── index.ts ├── Swagger ├── function.json └── index.ts ├── WebManifest ├── function.json └── index.ts ├── deprecated ├── Hint │ ├── function.json │ └── index.ts ├── ImageBase64 │ ├── function.json │ └── index.ts ├── Report_slow │ ├── function.json │ └── index.ts ├── ServiceWorker │ ├── function.json │ ├── index.ts │ └── sample.dat ├── Validate │ ├── function.json │ └── index.ts ├── browserLauncher.ts ├── icons.ts ├── schema.ts └── zip.ts ├── host.json ├── local.settings.json ├── package-lock.json ├── package.json ├── patch.js ├── playwright.config.ts ├── services └── imageGenerator.ts ├── tests ├── FindAuditServiceWorker.spec.ts ├── FindWebManifest.spec.ts ├── Report.spec.ts ├── drafts │ └── FetchWebManifest.draft.ts ├── helpers.ts ├── test_urls.json └── urls_for_test.json ├── tsconfig.json └── utils ├── Exception.ts ├── analytics.ts ├── analyzeServiceWorker.ts ├── base64.ts ├── checkParams.ts ├── fetch-headers.ts ├── getManifest.ts ├── getManifestByLink.ts ├── getManifestFromFile.ts ├── interfaces.ts ├── loadPage.ts ├── logMessages.ts ├── mani-tests.ts ├── manifest.ts ├── platform.ts ├── sas.ts ├── set.ts ├── storage.ts ├── testManifest.ts ├── urlLogger.ts └── w3c.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | local.settings.json -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | plugins: ["@typescript-eslint", "eslint-plugin-import"], 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:import/typescript", 10 | "plugin:eslint-plugin-import/recommended", 11 | "prettier", 12 | "prettier/@typescript-eslint", 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.funcignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules 3 | dist 4 | deprecated 5 | *.js.map 6 | .git* 7 | .vscode 8 | test 9 | temp -------------------------------------------------------------------------------- /.github/workflows/dev-to-dev.yml: -------------------------------------------------------------------------------- 1 | name: pwabuilder-apiv2-node to dev 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["dev"] 7 | pull_request: 8 | branches: ["dev"] 9 | 10 | 11 | env: 12 | AZURE_FUNCTIONAPP_NAME: 'pwabuilder-apiv2-node' 13 | AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' 14 | NODE_VERSION: '18.x' 15 | 16 | jobs: 17 | build-and-deploy: 18 | runs-on: ubuntu-latest 19 | environment: dev 20 | steps: 21 | - name: 'Checkout GitHub Action' 22 | uses: actions/checkout@v3 23 | 24 | - name: 'Login via Azure CLI' 25 | uses: azure/login@v1 26 | with: 27 | creds: ${{ secrets.AZURE_CREDENTIALS_PRINCIPAL }} # set up AZURE_RBAC_CREDENTIALS secrets in your repository 28 | 29 | - name: Setup Node ${{ env.NODE_VERSION }} Environment 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: ${{ env.NODE_VERSION }} 33 | 34 | - name: 'Resolve Project Dependencies Using Npm' 35 | shell: bash 36 | run: | 37 | pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' 38 | npm install 39 | npm run build 40 | popd 41 | - name: 'Run Azure Functions Action' 42 | uses: Azure/functions-action@v1 43 | id: fa 44 | with: 45 | app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} 46 | package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} 47 | slot-name: dev 48 | -------------------------------------------------------------------------------- /.github/workflows/master-to-prod.yml: -------------------------------------------------------------------------------- 1 | name: pwabuilder-apiv2-node to prod 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | 7 | env: 8 | AZURE_FUNCTIONAPP_NAME: 'pwabuilder-apiv2-node' 9 | AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' 10 | NODE_VERSION: '18.x' 11 | 12 | jobs: 13 | build-and-deploy: 14 | runs-on: self-hosted 15 | environment: dev 16 | steps: 17 | - name: 'Checkout GitHub Action' 18 | uses: actions/checkout@v3 19 | 20 | - name: 'Login via Azure CLI' 21 | uses: azure/login@v2 22 | with: 23 | auth-type: IDENTITY 24 | client-id: ${{ secrets.AZURE_CLIENT_ID }} 25 | tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_BECF5140E58D4E4DAF8C3ECDD90CB3D3 }} 26 | subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 27 | enable-AzPSSession: true 28 | 29 | - name: Setup Node ${{ env.NODE_VERSION }} Environment 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: ${{ env.NODE_VERSION }} 33 | 34 | - name: 'Resolve Project Dependencies Using Npm' 35 | shell: bash 36 | run: | 37 | pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' 38 | npm install 39 | npm run build 40 | popd 41 | 42 | - name: 'Run Azure Functions Action' 43 | uses: Azure/functions-action@v1 44 | id: fa 45 | with: 46 | app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} 47 | package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} 48 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master, dev ] 7 | jobs: 8 | test: 9 | timeout-minutes: 30 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps webkit 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: temp/report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | bin 4 | obj 5 | csx 6 | .vs 7 | edge 8 | Publish 9 | 10 | *.user 11 | *.suo 12 | *.cscfg 13 | *.Cache 14 | project.lock.json 15 | 16 | /packages 17 | /TestResults 18 | 19 | /tools/NuGet.exe 20 | /App_Data 21 | /secrets 22 | /data 23 | .secrets 24 | appsettings.json 25 | 26 | node_modules 27 | dist 28 | temp 29 | 30 | # Local python packages 31 | .python_packages/ 32 | 33 | # Python Environments 34 | .env 35 | .venv 36 | env/ 37 | venv/ 38 | ENV/ 39 | env.bak/ 40 | venv.bak/ 41 | 42 | # Byte-compiled / optimized / DLL files 43 | __pycache__/ 44 | *.py[cod] 45 | *$py.class 46 | 47 | # Azurite files 48 | __azurite_db_blob__.json 49 | __azurite_db_blob_extent__.json 50 | __blobstorage__ 51 | __queuestorage__ 52 | __azurite_db_queue__.json 53 | __azurite_db_queue_extent__.json 54 | /test-results/ 55 | /playwright-report/ 56 | /playwright/.cache/ 57 | tests/urls_for_test.json 58 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.14.2 2 | -------------------------------------------------------------------------------- /.openAPI/cli/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { promises as fs } from 'fs'; 3 | import { pathToFileURL } from 'url'; 4 | import { loadDefinition } from './utils.js'; 5 | import yaml from 'yaml'; 6 | 7 | import swaggerJsdoc from 'swagger-jsdoc'; 8 | 9 | /** 10 | * Handle CLI arguments in your preferred way. 11 | * @see https://nodejs.org/en/knowledge/command-line/how-to-parse-command-line-arguments/ 12 | */ 13 | const args = process.argv.slice(2); 14 | 15 | /** 16 | * Extract definition 17 | * Pass an absolute specifier with file:/// to the loader. 18 | * The relative and bare specifiers would be based on assumptions which are not stable. 19 | * For example, if path from cli `examples/app/parameters.*` goes in, it will be assumed as bare, which is wrong. 20 | */ 21 | 22 | const config = args.splice( 23 | args.findIndex((i) => i === '--config'), 24 | 2 25 | )[1]; 26 | 27 | const definitionUrl = pathToFileURL(config); 28 | 29 | // Because "Parsing error: Cannot use keyword 'await' outside an async function" 30 | (async () => { 31 | /** 32 | * We're using an example module loader which you can swap with your own implemenentation. 33 | */ 34 | const config = await loadDefinition(definitionUrl.pathname.replace(/^\//, '')); 35 | 36 | // Use the library 37 | const spec = await swaggerJsdoc(config); 38 | 39 | // Save specification place and format 40 | await fs.writeFile('./.openAPI/open-api.yaml', new yaml.Document(spec).toString()); 41 | // await fs.writeFile('./.openAPI/open-api.json', JSON.stringify(spec, null, 2)); 42 | 43 | console.log('Specification has been created successfully!'); 44 | })(); 45 | -------------------------------------------------------------------------------- /.openAPI/cli/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "definition": { 3 | "openapi": "3.0.3", 4 | "info": { 5 | "title": "PWA Builder API", 6 | "description": "Azure functions based API for PWABuilder, Tools and Extensions. [Repository](https://github.com/pwa-builder/pwabuilder-api-v2).", 7 | "version": "2.1.0" 8 | }, 9 | "servers": [ 10 | { 11 | "url": "/api" 12 | } 13 | ] 14 | }, 15 | "apis": ["./[A-Z]*/index.ts"] 16 | } -------------------------------------------------------------------------------- /.openAPI/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swagger-jsdoc-cli", 3 | "description": "cli for swagger-jsdoc", 4 | "version": "0.0.1", 5 | "private": true, 6 | "type": "module", 7 | "bin": "cli.js", 8 | "scripts": { 9 | "run": "node ./cli.js --config ./config.json"}, 10 | "engines": { 11 | "node": ">=12.0.0" 12 | }, 13 | "dependencies": { 14 | "swagger-jsdoc": "^7.0.0-rc.6", 15 | "yaml": "^2.2.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.openAPI/cli/reference-specification.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "Hello World", 4 | "version": "1.0.0", 5 | "description": "A sample API" 6 | }, 7 | "swagger": "2.0", 8 | "paths": { 9 | "/login": { 10 | "post": { 11 | "description": "Login to the application", 12 | "tags": ["Users", "Login"], 13 | "produces": ["application/json"], 14 | "parameters": [ 15 | { 16 | "$ref": "#/parameters/username" 17 | }, 18 | { 19 | "name": "password", 20 | "description": "User's password.", 21 | "in": "formData", 22 | "required": true, 23 | "type": "string" 24 | } 25 | ], 26 | "responses": { 27 | "200": { 28 | "description": "login", 29 | "schema": { 30 | "type": "object", 31 | "$ref": "#/definitions/Login" 32 | } 33 | } 34 | } 35 | } 36 | }, 37 | "/hello": { 38 | "get": { 39 | "description": "Returns the homepage", 40 | "responses": { 41 | "200": { 42 | "description": "hello world" 43 | } 44 | } 45 | } 46 | }, 47 | "/": { 48 | "get": { 49 | "description": "Returns the homepage", 50 | "responses": { 51 | "200": { 52 | "description": "hello world" 53 | } 54 | } 55 | } 56 | }, 57 | "/users": { 58 | "get": { 59 | "description": "Returns users", 60 | "tags": ["Users"], 61 | "produces": ["application/json"], 62 | "responses": { 63 | "200": { 64 | "description": "users" 65 | } 66 | } 67 | }, 68 | "post": { 69 | "description": "Returns users", 70 | "tags": ["Users"], 71 | "produces": ["application/json"], 72 | "parameters": [ 73 | { 74 | "$ref": "#/parameters/username" 75 | } 76 | ], 77 | "responses": { 78 | "200": { 79 | "description": "users" 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | "definitions": { 86 | "Login": { 87 | "required": ["username", "password"], 88 | "properties": { 89 | "username": { 90 | "type": "string" 91 | }, 92 | "password": { 93 | "type": "string" 94 | }, 95 | "path": { 96 | "type": "string" 97 | } 98 | } 99 | } 100 | }, 101 | "responses": {}, 102 | "parameters": { 103 | "username": { 104 | "name": "username", 105 | "description": "Username to use for login.", 106 | "in": "formData", 107 | "required": true, 108 | "type": "string" 109 | } 110 | }, 111 | "securityDefinitions": {}, 112 | "tags": [ 113 | { 114 | "name": "Users", 115 | "description": "User management and login" 116 | }, 117 | { 118 | "name": "Login", 119 | "description": "Login" 120 | }, 121 | { 122 | "name": "Accounts", 123 | "description": "Accounts" 124 | } 125 | ] 126 | } 127 | -------------------------------------------------------------------------------- /.openAPI/cli/utils.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | import { extname } from 'path'; 3 | import { promises as fs } from 'fs'; 4 | import yaml from 'yaml'; 5 | 6 | /** 7 | * @param {string} definitionPath path to the swaggerDefinition 8 | */ 9 | export async function loadDefinition(definitionPath) { 10 | const loadModule = async () => { 11 | const esmodule = await import(definitionPath); 12 | return esmodule.default; 13 | }; 14 | const loadCJS = () => { 15 | const require = createRequire(import.meta.url); 16 | return require(definitionPath); 17 | }; 18 | const loadJson = async () => { 19 | const fileContents = await fs.readFile(definitionPath); 20 | return JSON.parse(fileContents); 21 | }; 22 | const loadYaml = async () => { 23 | const fileContents = await fs.readFile(definitionPath); 24 | return yaml.parse(String(fileContents)); 25 | }; 26 | 27 | const LOADERS = { 28 | '.js': loadModule, 29 | '.mjs': loadModule, 30 | '.cjs': loadCJS, 31 | '.json': loadJson, 32 | '.yml': loadYaml, 33 | '.yaml': loadYaml, 34 | }; 35 | 36 | const loader = LOADERS[extname(definitionPath)]; 37 | 38 | if (loader === undefined) { 39 | throw new Error( 40 | `Definition file should be any of the following: ${Object.keys( 41 | LOADERS 42 | ).join(', ')}` 43 | ); 44 | } 45 | 46 | const result = await loader(); 47 | 48 | return result; 49 | } 50 | -------------------------------------------------------------------------------- /.openAPI/components.yaml: -------------------------------------------------------------------------------- 1 | schemas: 2 | manifest: 3 | $ref: ?file=manifest.yaml 4 | parameters: 5 | site: 6 | name: site 7 | schema: 8 | type: string 9 | default: https://webboard.app 10 | in: query 11 | description: Web application URL 12 | required: true 13 | properties: 14 | sw_features: 15 | type: object 16 | properties: 17 | detectedBackgroundSync: 18 | type: boolean 19 | detectedPeriodicBackgroundSync: 20 | type: boolean 21 | detectedPushRegistration: 22 | type: boolean 23 | detectedSignsOfLogic: 24 | type: boolean 25 | raw: 26 | type: string 27 | error: 28 | type: string 29 | responses: 30 | manifestGrab: 31 | '200': 32 | description: OK 33 | content: 34 | application/json: 35 | schema: 36 | type: object 37 | properties: 38 | content: 39 | type: object 40 | properties: 41 | json: 42 | $ref: '#/schemas/manifest' 43 | url: 44 | type: string 45 | data: 46 | type: object 47 | properties: 48 | recommended: 49 | type: object 50 | properties: 51 | background_color: 52 | type: string 53 | categories: 54 | type: string 55 | description: 56 | type: string 57 | iarc_rating: 58 | type: string 59 | maskable_icon: 60 | type: string 61 | orientation: 62 | type: string 63 | prefer_related_applications: 64 | type: string 65 | related_applications: 66 | type: string 67 | screenshots: 68 | type: string 69 | theme_color: 70 | type: string 71 | required: 72 | type: object 73 | properties: 74 | short_name: 75 | type: string 76 | name: 77 | type: string 78 | display: 79 | type: string 80 | start_url: 81 | type: string 82 | icons: 83 | type: string 84 | manifestGen: 85 | '200': 86 | description: OK 87 | content: 88 | application/json: 89 | schema: 90 | type: object 91 | properties: 92 | content: 93 | type: object 94 | $ref: ?file=manifest.yaml 95 | format: 96 | type: string 97 | generatedUrl: 98 | type: string 99 | security: 100 | '200': 101 | description: OK 102 | content: 103 | application/json: 104 | schema: 105 | type: object 106 | properties: 107 | data: 108 | type: object 109 | properties: 110 | isHTTPS: 111 | type: boolean 112 | validProtocol: 113 | type: boolean 114 | protocol: 115 | type: string 116 | valid: 117 | type: boolean 118 | report: 119 | '200': 120 | description: OK 121 | content: 122 | application/json: 123 | schema: 124 | type: object 125 | properties: 126 | data: 127 | type: object 128 | properties: 129 | audits: 130 | type: object 131 | properties: 132 | isOnHttps: 133 | type: object 134 | properties: 135 | score: 136 | type: boolean 137 | noMixedContent: 138 | type: object 139 | properties: 140 | score: 141 | type: boolean 142 | installableManifest: 143 | type: object 144 | properties: 145 | score: 146 | type: boolean 147 | details: 148 | type: object 149 | properties: 150 | url: 151 | type: string 152 | validation: 153 | type: array 154 | items: 155 | $ref: ?file=validation.yaml 156 | serviceWorker: 157 | type: object 158 | properties: 159 | score: 160 | type: boolean 161 | details: 162 | type: object 163 | properties: 164 | url: 165 | type: string 166 | scope: 167 | type: boolean 168 | features: 169 | $ref: '#/properties/sw_features' 170 | artifacts: 171 | type: object 172 | properties: 173 | webAppManifest: 174 | type: object 175 | properties: 176 | raw: 177 | type: string 178 | url: 179 | type: string 180 | json: 181 | type: object 182 | serviceWorker: 183 | type: object 184 | properties: 185 | raw: 186 | type: string 187 | url: 188 | type: string 189 | error: 190 | type: string 191 | enum: [ 192 | "UnexpectedError", 193 | "AuditFailed", 194 | "TimeoutError" 195 | ] 196 | nullable: true 197 | 198 | -------------------------------------------------------------------------------- /.openAPI/endpoints.md: -------------------------------------------------------------------------------- 1 | ## **API v2 Functions:** 2 | 3 | ### **👷Alive: (active use >1.5K req per month)** 4 | 5 | FetchWebManifest: [GET,POST] http://localhost:7071/api/FetchWebManifest 6 | 7 | Security: [GET,POST] http://localhost:7071/api/Security 8 | 9 | ### **🚑 In Hospital: (50-150 req per month)** 10 | 11 | WebManifest: [GET,POST] http://localhost:7071/api/WebManifest 12 | 13 | Offline: [GET,POST] http://localhost:7071/api/Offline 14 | 15 | ### **🧟 Zombie: (1-5 req per month)** 16 | 17 | GenerateManifest: [GET,POST] http://localhost:7071/api/GenerateManifest 18 | 19 | Site: [GET,POST] http://localhost:7071/api/Site 20 | 21 | 22 | ### **🪦Dead:** 23 | ~~ImageBase64: [GET,POST] http://localhost:7071/api/ImageBase64~~ 24 | 25 | ~~ServiceWorker: [GET,POST] http://localhost:7071/api/ServiceWorker~~ 26 | 27 | ~~Validate: [GET,POST] http://localhost:7071/api/Validate~~ -------------------------------------------------------------------------------- /.openAPI/manifest.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | properties: 3 | dir: 4 | type: string 5 | lang: 6 | type: string 7 | name: 8 | type: string 9 | short_name: 10 | type: string 11 | description: 12 | type: string 13 | categories: 14 | type: string 15 | iarc_rating_id: 16 | type: string 17 | start_url: 18 | type: string 19 | icons: 20 | type: array 21 | items: 22 | type: object 23 | screenshots: 24 | type: array 25 | items: 26 | type: object 27 | display: 28 | type: string 29 | theme_color: 30 | type: string 31 | background_color: 32 | type: string 33 | scope: 34 | type: string 35 | related_applications: 36 | type: array 37 | items: 38 | type: string 39 | prefer_related_application: 40 | type: boolean 41 | shortcuts: 42 | type: array 43 | items: 44 | type: string -------------------------------------------------------------------------------- /.openAPI/open-api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: PWA Builder API 4 | description: Azure functions based API for PWABuilder, Tools and Extensions. 5 | [Repository](https://github.com/pwa-builder/pwabuilder-api-v2). 6 | version: 2.1.0 7 | servers: 8 | - url: /api 9 | paths: 10 | /AuditServiceWorker: 11 | get: 12 | summary: Audit service worker 13 | description: Generate features audit report for service worker by url 14 | tags: 15 | - Report 16 | parameters: 17 | - name: url 18 | schema: 19 | type: string 20 | default: https://webboard.app/sw.js 21 | in: query 22 | description: Service worker file URL 23 | required: true 24 | responses: 25 | "200": 26 | description: OK 27 | content: 28 | application/json: 29 | schema: 30 | type: object 31 | properties: 32 | content: 33 | type: object 34 | properties: 35 | score: 36 | type: boolean 37 | details: 38 | type: object 39 | properties: 40 | url: 41 | type: string 42 | features: 43 | $ref: ?file=components.yaml#/properties/sw_features 44 | /FetchWebManifest: 45 | get: 46 | deprecated: true 47 | summary: Manifest file 48 | description: Detect and grab manifest json and url from webapp 49 | tags: 50 | - Generate 51 | parameters: 52 | - $ref: ?file=components.yaml#/parameters/site 53 | responses: 54 | "200": 55 | $ref: ?file=components.yaml#/responses/manifestGrab/200 56 | /FindServiceWorker: 57 | get: 58 | summary: Fast service worker detection 59 | description: Try to detect service worker and return it url and raw content 60 | tags: 61 | - Generate 62 | parameters: 63 | - $ref: ?file=components.yaml#/parameters/site 64 | responses: 65 | "200": 66 | description: OK 67 | content: 68 | application/json: 69 | schema: 70 | type: object 71 | properties: 72 | content: 73 | type: object 74 | properties: 75 | url: 76 | type: string 77 | raw: 78 | type: string 79 | /FindWebManifest: 80 | get: 81 | summary: Fast web manifest detection 82 | description: Try to detect web manifest and return it url, raw and json content 83 | tags: 84 | - Generate 85 | parameters: 86 | - $ref: ?file=components.yaml#/parameters/site 87 | responses: 88 | "200": 89 | description: OK 90 | content: 91 | application/json: 92 | schema: 93 | type: object 94 | properties: 95 | content: 96 | type: object 97 | properties: 98 | url: 99 | type: string 100 | json: 101 | $ref: ?file=components.yaml#/schemas/manifest 102 | /GenerateManifest: 103 | get: 104 | deprecated: true 105 | summary: Generate manifest file 106 | description: Detect and parse or generate manifest json from webapp 107 | tags: 108 | - Generate 109 | parameters: 110 | - $ref: ?file=components.yaml#/parameters/site 111 | responses: 112 | "200": 113 | $ref: ?file=components.yaml#/responses/manifestGen/200 114 | /Offline: 115 | get: 116 | deprecated: true 117 | summary: Check offline 118 | description: Validate webapp for offline support 119 | tags: 120 | - Validate 121 | parameters: 122 | - $ref: ?file=components.yaml#/parameters/site 123 | responses: 124 | "200": 125 | description: OK 126 | content: 127 | application/json: 128 | schema: 129 | type: object 130 | properties: 131 | data: 132 | type: object 133 | properties: 134 | offline: 135 | type: string 136 | /Report: 137 | get: 138 | summary: Lighthouse report 139 | description: Generate PWA-related Lighthouse report for webapp 140 | tags: 141 | - Report 142 | parameters: 143 | - $ref: ?file=components.yaml#/parameters/site 144 | - name: desktop 145 | schema: 146 | type: boolean 147 | in: query 148 | description: Use desktop form factor 149 | required: false 150 | - name: validation 151 | schema: 152 | type: boolean 153 | in: query 154 | description: Include manifest fields validation 155 | required: false 156 | responses: 157 | "200": 158 | $ref: ?file=components.yaml#/responses/report/200 159 | /Security: 160 | get: 161 | deprecated: true 162 | summary: Check webapp security 163 | description: Validate webapp security protocols 164 | tags: 165 | - Validate 166 | parameters: 167 | - $ref: ?file=components.yaml#/parameters/site 168 | responses: 169 | "200": 170 | $ref: ?file=components.yaml#/responses/security/200 171 | /Site: 172 | get: 173 | deprecated: true 174 | summary: Custom report 175 | description: Custom manifest validation 176 | tags: 177 | - Report 178 | parameters: 179 | - $ref: ?file=components.yaml#/parameters/site 180 | responses: 181 | "200": 182 | description: OK 183 | content: 184 | application/json: 185 | schema: 186 | type: object 187 | properties: 188 | content: 189 | type: object 190 | $ref: ?file=manifest.yaml 191 | format: 192 | type: string 193 | generatedUrl: 194 | type: string 195 | id: 196 | type: number 197 | default: 198 | type: object 199 | errors: 200 | type: array 201 | items: 202 | type: object 203 | suggestions: 204 | type: array 205 | items: 206 | type: object 207 | warnings: 208 | type: array 209 | items: 210 | type: object 211 | /WebManifest: 212 | post: 213 | deprecated: true 214 | summary: Check webmanifest 215 | description: Validate webapp manifest 216 | tags: 217 | - Validate 218 | parameters: 219 | - name: site 220 | schema: 221 | type: string 222 | default: https://webboard.app 223 | in: query 224 | description: Web application URL 225 | requestBody: 226 | description: Optional body params 227 | content: 228 | application/json: 229 | schema: 230 | type: object 231 | properties: 232 | manifest: 233 | type: object 234 | default: null 235 | maniurl: 236 | type: string 237 | default: null 238 | responses: 239 | "200": 240 | $ref: ?file=components.yaml#/responses/manifestGrab/200 241 | components: {} 242 | tags: [] 243 | -------------------------------------------------------------------------------- /.openAPI/swagger-node-middleware.js: -------------------------------------------------------------------------------- 1 | // don't work anymore, for history only 2 | 3 | import express from 'express' 4 | import httpProxy from 'http-proxy' 5 | const apiProxy = httpProxy.createProxyServer( 6 | {target: { host: '0.0.0.0', port: 7071, protocol: 'http' }, changeOrigin: true} 7 | ) 8 | 9 | import fs from 'fs' 10 | import path, { dirname } from 'path'; 11 | import { fileURLToPath } from 'url'; 12 | import { absolutePath } from 'swagger-ui-dist' 13 | 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); 15 | const pathToSwaggerUi = absolutePath(); 16 | 17 | const indexContent = fs.readFileSync(`${pathToSwaggerUi}/swagger-initializer.js`) 18 | .toString() 19 | .replace("https://petstore.swagger.io/v2/swagger.json", "/openAPI/open-api.yaml") 20 | 21 | const app = express() 22 | app.get("/swagger-initializer.js", (req, res) => res.send(indexContent)) 23 | app.use(express.static(pathToSwaggerUi)) 24 | app.use('/openAPI', express.static(path.join(__dirname, '.'))) 25 | 26 | app.all(/\/api\/*/, function(req, res) { 27 | apiProxy.web(req, res); 28 | }); 29 | 30 | app.listen(80) -------------------------------------------------------------------------------- /.openAPI/validation.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | properties: 3 | infoString: 4 | type: string 5 | displayString: 6 | type: string 7 | category: 8 | type: string 9 | member: 10 | type: string 11 | defaultValue: 12 | oneOf: 13 | - type: string 14 | - type: boolean 15 | - type: array 16 | docsLink: 17 | type: string 18 | errorString: 19 | type: string 20 | quickFix: 21 | type: boolean 22 | # test: 23 | # type: string 24 | testRequired: 25 | type: boolean 26 | testName: 27 | type: string 28 | valid: 29 | type: boolean -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true, 6 | "quoteProps": "consistent", 7 | "trailingComma": "es5", 8 | "endOfLine": "lf", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /.puppeteerrc.cjs: -------------------------------------------------------------------------------- 1 | const {join} = require('path'); 2 | 3 | /** 4 | * @type {import("puppeteer").Configuration} 5 | */ 6 | module.exports = { 7 | // Changes the cache location for Puppeteer. 8 | cacheDirectory: join(__dirname, '.cache', 'puppeteer'), 9 | }; 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug local", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 5858, 9 | "preLaunchTask": "func: host start" 10 | }, 11 | { 12 | "name": "Connect to Func node debugger", 13 | "type": "node", 14 | "request": "attach", 15 | "port": 5858 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": ".", 3 | "azureFunctions.projectLanguage": "TypeScript", 4 | "azureFunctions.projectRuntime": "~4", 5 | "azureFunctions.scmDoBuildDuringDeployment": true, 6 | "debug.internalConsoleOptions": "neverOpen", 7 | // "azureFunctions.preDeployTask": "npm prune", 8 | "editor.tabSize": 2 9 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-watch", 8 | "isBackground": true, 9 | "dependsOn": "npm build" 10 | }, 11 | { 12 | "type": "shell", 13 | "label": "npm build", 14 | "command": "npm run build", 15 | "dependsOn": "npm install", 16 | "problemMatcher": "$tsc" 17 | }, 18 | { 19 | "type": "shell", 20 | "label": "npm install", 21 | "command": "npm install" 22 | }, 23 | { 24 | "type": "shell", 25 | "label": "npm prune", 26 | "command": "npm prune --production", 27 | "dependsOn": "npm build", 28 | "problemMatcher": [] 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /AuditServiceWorker/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/AuditServiceWorker/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /AuditServiceWorker/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import { checkParams } from '../utils/checkParams.js'; 3 | import { analyzeServiceWorker } from '../utils/analyzeServiceWorker.js'; 4 | 5 | 6 | const httpTrigger: AzureFunction = async function ( 7 | context: Context, 8 | req: HttpRequest 9 | ): Promise { 10 | 11 | const checkResult = checkParams(req, ['url']); 12 | if (checkResult.status !== 200){ 13 | context.res = checkResult; 14 | context.log.error(`AuditServiceWorker: ${checkResult.body?.error.message}`); 15 | return; 16 | } 17 | 18 | const url = req?.query?.url; 19 | 20 | context.log( 21 | `AuditServiceWorker: function is processing a request for url: ${url}` 22 | ); 23 | 24 | try { 25 | 26 | const swFeatures = await analyzeServiceWorker(url); 27 | 28 | context.res = { 29 | status: 200, 30 | body: { 31 | content: { 32 | score: !swFeatures.error ? true : false, 33 | details: { 34 | url: url, 35 | features: swFeatures, 36 | } 37 | }, 38 | }, 39 | }; 40 | 41 | context.log.info( 42 | `AuditServiceWorker: function is DONE processing for url: ${url}` 43 | ); 44 | 45 | } catch (err: any) { 46 | context.res = { 47 | status: 400, 48 | body: { 49 | error: { error: err, message: err?.message || null }, 50 | }, 51 | }; 52 | 53 | context.log.error( 54 | `AuditServiceWorker: function has ERRORED while processing for url: ${url} with this error: ${err?.message || err}` 55 | ); 56 | } 57 | }; 58 | 59 | export default httpTrigger; 60 | 61 | /** 62 | * @openapi 63 | * /AuditServiceWorker: 64 | * get: 65 | * summary: Audit service worker 66 | * description: Generate features audit report for service worker by url 67 | * tags: 68 | * - Report 69 | * parameters: 70 | * - name: url 71 | * schema: 72 | * type: string 73 | * default: https://webboard.app/sw.js 74 | * in: query 75 | * description: Service worker file URL 76 | * required: true 77 | * responses: 78 | * '200': 79 | * description: OK 80 | * content: 81 | * application/json: 82 | * schema: 83 | * type: object 84 | * properties: 85 | * content: 86 | * type: object 87 | * properties: 88 | * score: 89 | * type: boolean 90 | * details: 91 | * type: object 92 | * properties: 93 | * url: 94 | * type: string 95 | * features: 96 | * $ref: ?file=components.yaml#/properties/sw_features 97 | */ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Docker is for some debuggin puproses only (to check under WSL for example), not for production 2 | # docker build -t api-v2 . 3 | # docker run -p 80:7071 api-v2 4 | 5 | FROM mcr.microsoft.com/azure-functions/node:4-node20 6 | 7 | 8 | # functions api port 9 | EXPOSE 7071 10 | 11 | 12 | RUN apt-get update \ 13 | && apt-get install -y wget gnupg ca-certificates \ 14 | && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \ 15 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 16 | && apt-get update \ 17 | # We install Chrome to get all the OS level dependencies, but Chrome itself 18 | # is not actually used as it's packaged in the node puppeteer library. 19 | # Alternatively, we could could include the entire dep list ourselves 20 | # (https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix) 21 | # but that seems too easy to get out of date. 22 | # adding a dependency for keytar 23 | && apt-get install -y google-chrome-stable libsecret-1-dev \ 24 | && rm -rf /var/lib/apt/lists/* \ 25 | && wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh \ 26 | && chmod +x /usr/sbin/wait-for-it.sh 27 | 28 | ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ 29 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ 30 | ASPNETCORE_URLS=http://*:7071 31 | 32 | 33 | COPY . /home/site/wwwroot 34 | 35 | WORKDIR /home/site/wwwroot 36 | RUN rm -rf node_modules && \ 37 | npm install && \ 38 | npm run build -------------------------------------------------------------------------------- /FetchWebManifest/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/FetchWebManifest/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /FetchWebManifest/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import { checkParams } from '../utils/checkParams.js'; 3 | import { getManifestTwoWays } from '../utils/getManifest.js'; 4 | import testManifest from '../utils/testManifest.js'; 5 | 6 | const httpTrigger: AzureFunction = async function ( 7 | context: Context, 8 | req: HttpRequest 9 | ): Promise { 10 | 11 | const checkResult = checkParams(req, ['site']); 12 | if (checkResult.status !== 200){ 13 | context.res = checkResult; 14 | context.log.error(`FetchWebManifest: ${checkResult.body?.error.message}`); 15 | return; 16 | } 17 | 18 | const site = req?.query?.site; 19 | const maniObject = req?.body?.manifest; 20 | const maniUrl = req?.body?.maniurl; 21 | 22 | context.log( 23 | `Web Manifest function is processing a request for site: ${site}` 24 | ); 25 | 26 | try { 27 | if (maniObject && (maniUrl || site)) { 28 | context.log.info( 29 | `Web Manifest function has a raw manifest object for site: ${req?.query?.site}` 30 | ); 31 | 32 | const results = await testManifest(maniObject); 33 | 34 | context.res = { 35 | status: 200, 36 | body: { 37 | data: results, 38 | content: { 39 | json: maniObject, 40 | url: maniUrl || site, 41 | }, 42 | }, 43 | }; 44 | 45 | context.log.info( 46 | `Web Manifest function is DONE processing for site: ${req.query.site}` 47 | ); 48 | } else if (site) { 49 | context.log.info( 50 | `Web Manifest function is grabbing manifest object for site: ${req.query.site}` 51 | ); 52 | // const start = new Date().getTime(); 53 | const maniData = await getManifestTwoWays(site, context); 54 | // const elapsed = new Date().getTime() - start; 55 | // context.log('TIME ELAPSED', elapsed); 56 | if (maniData) { 57 | const results = await testManifest(maniObject); 58 | 59 | context.res = { 60 | status: 200, 61 | body: { 62 | data: results, 63 | content: maniData, 64 | }, 65 | }; 66 | 67 | context.log.info( 68 | `Web Manifest function is DONE processing for site: ${req.query.site}` 69 | ); 70 | } 71 | } 72 | } catch (err: any) { 73 | context.res = { 74 | status: 400, 75 | body: { 76 | error: { error: err, message: err.message }, 77 | }, 78 | }; 79 | 80 | context.log.error( 81 | `Web Manifest function has ERRORED while processing for site: ${req.query.site} with this error: ${err.message}` 82 | ); 83 | } 84 | }; 85 | 86 | export default httpTrigger; 87 | 88 | /** 89 | * @openapi 90 | * /FetchWebManifest: 91 | * get: 92 | * deprecated: true 93 | * summary: Manifest file 94 | * description: 'Detect and grab manifest json and url from webapp' 95 | * tags: 96 | * - Generate 97 | * parameters: 98 | * - $ref: '?file=components.yaml#/parameters/site' 99 | * responses: 100 | * '200': 101 | * $ref: '?file=components.yaml#/responses/manifestGrab/200' 102 | */ -------------------------------------------------------------------------------- /FindServiceWorker/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/FindServiceWorker/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /FindServiceWorker/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import fetch from 'node-fetch'; 3 | import { checkParams } from '../utils/checkParams.js'; 4 | import { userAgents } from 'lighthouse/core/config/constants.js'; 5 | import puppeteer from 'puppeteer'; 6 | 7 | const USER_AGENT = `${userAgents.desktop} PWABuilderHttpAgent`; 8 | const SKIP_RESOURCES = [ 'stylesheet', 'font', 'image', 'imageset', 'media', 'ping', 'manifest'] 9 | 10 | const httpTrigger: AzureFunction = async function ( 11 | context: Context, 12 | req: HttpRequest 13 | ): Promise { 14 | 15 | const checkResult = checkParams(req, ['site']); 16 | if (checkResult.status !== 200){ 17 | context.res = checkResult; 18 | context.log.error(`FindServiceWorker: ${checkResult.body?.error.message}`); 19 | return; 20 | } 21 | 22 | let site = req?.query?.site; 23 | 24 | context.log( 25 | `FindServiceWorker: function is processing a request for site: ${site}` 26 | ); 27 | 28 | try { 29 | let link: string | null | undefined = null; 30 | try{ 31 | const controller = new AbortController(); 32 | const timeoutId = setTimeout(() => controller.abort(), 5000); 33 | const response = await fetch(site, { signal: controller.signal, redirect: 'manual', headers: { 'User-Agent': USER_AGENT } }); 34 | clearTimeout(timeoutId); 35 | 36 | const html = await response.text(); 37 | 38 | const match = html.match(/navigator\s*\.\s*serviceWorker\s*\.\s*register\(\s*['"](.*?)['"]/) || html.match(/new Workbox\s*\(\s*['"](.*)['"]/); 39 | link = match? match[1] : null; 40 | 41 | if (link) { 42 | if (!link.startsWith('http') && !link.startsWith('data:')) { 43 | site = response.url; 44 | link = new URL(link, site).href; 45 | } 46 | } 47 | } catch (error) { 48 | context.log.warn(`FindServiceWorker: ${error}`); 49 | } 50 | 51 | if (link) { 52 | context.res = await returnWorkerContent(link, site, context); 53 | } 54 | else { 55 | context?.log.warn(`FindServiceWorker: trying slow mode`); 56 | 57 | const browser = await puppeteer.launch({headless: true , args: ['--no-sandbox', '--disable-setuid-sandbox']}); 58 | const page = await browser.newPage(); 59 | await page.setUserAgent(USER_AGENT); 60 | await page.setRequestInterception(true); 61 | 62 | try { 63 | page.on('request', (req) => { 64 | // commented because it doesn't work on Azure environment 65 | // if(SKIP_RESOURCES.some((type) => req.resourceType() == type)){ 66 | // req.abort(); 67 | // } 68 | // else { 69 | req.continue(); 70 | // } 71 | }); 72 | 73 | try { 74 | await page.goto(site, {timeout: 15000, waitUntil: 'load'}); 75 | await page.waitForNetworkIdle({ timeout: 3000, idleTime: 1000}); 76 | } catch(err) {} 77 | 78 | // trying to find manifest in html if request was unsuccessful 79 | if (!page.isClosed()) { 80 | try { 81 | link = await page.evaluate(() => { 82 | if ('serviceWorker' in navigator) { 83 | return navigator.serviceWorker.getRegistration() 84 | .then(registration => { 85 | if (registration) { 86 | return registration.active?.scriptURL || registration.installing?.scriptURL || registration.waiting?.scriptURL 87 | } 88 | }) 89 | .catch(error => null); 90 | } else { 91 | return null; 92 | } 93 | }); 94 | } catch (err) {} 95 | } 96 | } catch (error) { 97 | throw error; 98 | } finally { 99 | await browser.close(); 100 | } 101 | 102 | if (link) { 103 | context.res = await returnWorkerContent(link, site, context); 104 | } 105 | else { 106 | context.res = { 107 | status: 400, 108 | body: { 109 | error: { message: "No service worker found" }, 110 | }, 111 | }; 112 | 113 | context.log.warn( 114 | `FindServiceWorker: function has ERRORED while processing for site: ${site} with this error: No service worker found` 115 | ); 116 | } 117 | } 118 | } catch (err: any) { 119 | context.res = { 120 | status: 400, 121 | body: { 122 | error: { error: err, message: err.message }, 123 | }, 124 | }; 125 | 126 | context.log.error( 127 | `FindServiceWorker: function has ERRORED while processing for site: ${site} with this error: ${err.message}` 128 | ); 129 | } 130 | }; 131 | 132 | async function returnWorkerContent(url: string, site: string, context: Context) { 133 | let content: null | string = null; 134 | 135 | try { 136 | const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT }}); 137 | if (response.ok) { 138 | content = await response.text(); 139 | } 140 | } catch (error) {} 141 | 142 | 143 | 144 | context.log.info( 145 | `FindServiceWorker: function is DONE processing for site: ${site}` 146 | ); 147 | 148 | return { 149 | status: 200, 150 | body: { 151 | content: { 152 | raw: content, 153 | url: url, 154 | }, 155 | }, 156 | }; 157 | } 158 | 159 | export default httpTrigger; 160 | 161 | /** 162 | * @openapi 163 | * /FindServiceWorker: 164 | * get: 165 | * summary: Fast service worker detection 166 | * description: Try to detect service worker and return it url and raw content 167 | * tags: 168 | * - Generate 169 | * parameters: 170 | * - $ref: ?file=components.yaml#/parameters/site 171 | * responses: 172 | * '200': 173 | * description: OK 174 | * content: 175 | * application/json: 176 | * schema: 177 | * type: object 178 | * properties: 179 | * content: 180 | * type: object 181 | * properties: 182 | * url: 183 | * type: string 184 | * raw: 185 | * type: string 186 | */ 187 | -------------------------------------------------------------------------------- /FindWebManifest/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/FindWebManifest/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /GenerateManifest/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/GenerateManifest/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /GenerateManifest/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions"; 2 | import { ExceptionMessage, ExceptionWrap } from "../utils/Exception.js"; 3 | import { Manifest } from "../utils/interfaces.js"; 4 | import { checkParams } from "../utils/checkParams.js"; 5 | 6 | import pkg from 'pwabuilder-lib'; 7 | const { manifestTools } = pkg; 8 | 9 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 10 | 11 | const checkResult = checkParams(req, ['site']); 12 | if (checkResult.status !== 200){ 13 | context.res = checkResult; 14 | context.log.error(`GenerateManifest: ${checkResult.body?.error.message}`); 15 | return; 16 | } 17 | 18 | context.log.info(`GenerateManifest function is processing a request for site: ${req.query.site}`); 19 | 20 | let manifest: Manifest | null = null; 21 | const url = req.query.site; 22 | 23 | try { 24 | if (url) { 25 | const generated_mani = await manifestTools.getManifestFromSite(url); 26 | 27 | if (generated_mani) { 28 | manifest = generated_mani; 29 | 30 | context.res = { 31 | // status: 200, /* Defaults to 200 */ 32 | body: manifest 33 | }; 34 | } 35 | } 36 | else { 37 | context.res = { 38 | status: 400, 39 | body: "No site URL to generate a manifest with was passed" 40 | } 41 | 42 | context.log.error(`GenerateManifest function was called without a site URL`); 43 | } 44 | } 45 | catch (exception) { 46 | if (exception instanceof ExceptionWrap) { 47 | context.res = { 48 | status: 400, 49 | body: { 50 | message: ExceptionMessage[exception.type], 51 | }, 52 | }; 53 | 54 | context.log.error(`GenerateManifest function errored generating the manifest for site: ${req.query.site} with error: ${exception}`); 55 | } else { 56 | context.res = { 57 | status: 400, 58 | }; 59 | 60 | context.log.error(`GenerateManifest function errored generating the manifest for site: ${req.query.site}`, exception); 61 | } 62 | } 63 | 64 | }; 65 | 66 | export default httpTrigger; 67 | 68 | /** 69 | * @openapi 70 | * /GenerateManifest: 71 | * get: 72 | * deprecated: true 73 | * summary: Generate manifest file 74 | * description: 'Detect and parse or generate manifest json from webapp' 75 | * tags: 76 | * - Generate 77 | * parameters: 78 | * - $ref: '?file=components.yaml#/parameters/site' 79 | * responses: 80 | * '200': 81 | * $ref: '?file=components.yaml#/responses/manifestGen/200' 82 | */​ 83 | 84 | -------------------------------------------------------------------------------- /GenerateManifest/sample.dat: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure" 3 | } -------------------------------------------------------------------------------- /Offline/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Offline/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /Offline/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions"; 2 | import { Browser } from 'puppeteer'; 3 | import { OfflineTestData } from "../utils/interfaces.js"; 4 | import lighthouse from 'lighthouse'; 5 | import { closeBrowser, getBrowser } from "../utils/loadPage.js"; 6 | import { logOfflineResult } from "../utils/urlLogger.js"; 7 | import { checkParams } from '../utils/checkParams.js'; 8 | 9 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 10 | 11 | const checkResult = checkParams(req, ['site']); 12 | if (checkResult.status !== 200){ 13 | context.res = checkResult; 14 | context.log.error(`Offline: ${checkResult.body?.error.message}`); 15 | return; 16 | } 17 | 18 | context.log.info(`Offline function is processing a request for site: ${req.query.site}`); 19 | 20 | const url = req?.query?.site as string; 21 | 22 | const currentBrowser = await getBrowser(context); 23 | 24 | try { 25 | // run lighthouse audit 26 | 27 | if (currentBrowser) { 28 | const swInfo = await audit(currentBrowser, url); 29 | context.res = { 30 | status: 200, 31 | body: { 32 | "data": swInfo 33 | } 34 | } 35 | 36 | logOfflineResult(url, swInfo?.worksOffline === true); 37 | context.log.info(`Offline function is DONE processing a request for site: ${req.query.site}`); 38 | } 39 | 40 | } catch (error) { 41 | context.res = { 42 | status: 500, 43 | body: { 44 | error: error 45 | } 46 | }; 47 | 48 | const typedError = error as Error; 49 | if (typedError.name && typedError.name.indexOf('TimeoutError') > -1) { 50 | context 51 | context.log.error(`Offline function TIMED OUT processing a request for site: ${url}`); 52 | } else { 53 | context.log.error(`Offline function failed for ${url} with the following error: ${error}`) 54 | } 55 | logOfflineResult(url, false); 56 | } finally { 57 | await closeBrowser(context, currentBrowser); 58 | } 59 | }; 60 | 61 | const audit = async (browser: Browser, url: string): Promise => { 62 | // empty object that we fill with data below 63 | let swInfo: any = {}; 64 | 65 | const options = { 66 | logLevel: 'info', 67 | disableDeviceEmulation: true, 68 | chromeFlags: ['--disable-mobile-emulation', '--disable-storage-reset'], 69 | onlyAudits: ['installable-manifest'], 70 | output: 'json', 71 | port: Number((new URL(browser.wsEndpoint())).port) 72 | }; 73 | // @ts-ignore 74 | const runnerResult = await lighthouse(url, options); 75 | const audits = runnerResult?.lhr?.audits; 76 | 77 | if (audits) { 78 | // @ts-ignore 79 | swInfo.offline = audits['installable-manifest']?.score >= 1 ? true : false; 80 | 81 | return swInfo; 82 | } 83 | else { 84 | return null; 85 | } 86 | 87 | } 88 | 89 | export default httpTrigger; 90 | 91 | /** 92 | * @openapi 93 | * /Offline: 94 | * get: 95 | * deprecated: true 96 | * summary: Check offline 97 | * description: Validate webapp for offline support 98 | * tags: 99 | * - Validate 100 | * parameters: 101 | * - $ref: ?file=components.yaml#/parameters/site 102 | * responses: 103 | * '200': 104 | * description: 'OK' 105 | * content: 106 | * application/json: 107 | * schema: 108 | * type: object 109 | * properties: 110 | * data: 111 | * type: object 112 | * properties: 113 | * offline: 114 | * type: string 115 | */​ 116 | -------------------------------------------------------------------------------- /Offline/sample.dat: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Please use our [main repository for any issues/bugs/features suggestion](https://github.com/pwa-builder/PWABuilder/issues/new/choose). 2 | 3 | # pwabuilder-tests 4 | 5 | Azure functions that use Puppeteer + Chromium to test websites to see if they are a Progressive Web App and if so return information about that PWA. These functions power [PWABuilder](https://www.pwabuilder.com), but can also be cloned and run independently. 6 | 7 | ## Test Details 8 | 9 | Details about what info is pulled from each PWA. All of this info is gathered using Puppeteer and its APIs. 10 | 11 | ### Required: 12 | - A manifest with at least the following: 13 | - Name * 14 | - Short name * 15 | - Start url * 16 | - Icons ** 17 | - Display mode * 18 | 19 | - A service worker that has cache handlers 20 | 21 | - Valid https cert with no mixed content 22 | 23 | If a site has these things then it is a PWA. 24 | 25 | ### Recommended 26 | - Screenshots 27 | - Categories 28 | - Iarc rating 29 | - Related applications 30 | - Prefer related 31 | - Background color 32 | - Theme color 33 | - Description 34 | - Orientation 35 | - At least one maskable icon 36 | - At least one monochrome icon\ 37 | - At least one 512x512 or larger square icon] 38 | Offline support 39 | 40 | If a PWA has these then it is a store ready PWA, such as for the Microsoft or Google Play store. 41 | 42 | ## Development 43 | 44 | https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference 45 | https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-node 46 | 47 | 48 | -------------------------------------------------------------------------------- /Report/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Report/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/develop.md: -------------------------------------------------------------------------------- 1 | https://github.com/GoogleChrome/lighthouse/blob/main/docs/recipes/custom-gatherer-puppeteer/readme.md 2 | 3 | npm i 4 | npx lighthouse --config-path=custom-config.js https://webboard.app -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/https/https-audit.ts: -------------------------------------------------------------------------------- 1 | import {Audit, NetworkRecords} from 'lighthouse'; 2 | import * as LH from 'lighthouse/types/lh.js'; 3 | 4 | class HttpsAudit extends Audit { 5 | static get meta(): LH.Audit.Meta { 6 | return { 7 | id: 'https-audit', 8 | title: 'HTTPS Security', 9 | failureTitle: 'Page have HTTPS Security issues', 10 | description: 'Simple HTTPS Security audit', 11 | 12 | // The name of the custom gatherer class that provides input to this audit. 13 | // @ts-ignore 14 | requiredArtifacts: ['devtoolsLogs', 'URL'], 15 | }; 16 | } 17 | 18 | static async audit(artifacts: LH.Artifacts, context: LH.Audit.Context) { 19 | const devtoolsLogs = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; 20 | const networkRecords = await NetworkRecords.request(devtoolsLogs, context); 21 | const finalRecord = networkRecords.find(record => (record.url == artifacts.URL.finalDisplayedUrl || record.url == artifacts.URL.requestedUrl || record.url == artifacts.URL.mainDocumentUrl)); 22 | // finalRecord?.isSecure; 23 | // finalRecord?.protocol; 24 | 25 | 26 | return { 27 | // Cast true/false to 1/0 28 | score: Number(finalRecord?.isSecure && finalRecord?.isValid), 29 | }; 30 | } 31 | } 32 | 33 | export default HttpsAudit; -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/offline/offline-audit.ts: -------------------------------------------------------------------------------- 1 | import {Audit} from 'lighthouse'; 2 | import * as LH from 'lighthouse/types/lh.js'; 3 | 4 | 5 | class OfflineAudit extends Audit { 6 | static get meta(): LH.Audit.Meta { 7 | return { 8 | id: 'offline-audit', 9 | title: 'Offline Support Audit', 10 | failureTitle: 'Default page is not available offline', 11 | description: 'Simple offline support audit', 12 | 13 | // The name of the custom gatherer class that provides input to this audit. 14 | // @ts-ignore 15 | requiredArtifacts: ['OfflineGatherer', 'ServiceWorkerGatherer', 'WebAppManifest'], 16 | }; 17 | } 18 | 19 | static audit(artifacts) { 20 | const response = artifacts.OfflineGatherer; 21 | const success = (response.status == 200) && response.fromServiceWorker; 22 | 23 | return { 24 | // Cast true/false to 1/0 25 | score: Number(success), 26 | }; 27 | } 28 | } 29 | 30 | export default OfflineAudit; -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/offline/offline-gatherer.ts: -------------------------------------------------------------------------------- 1 | import {Gatherer} from 'lighthouse'; 2 | import * as LH from 'lighthouse/types/lh.js'; 3 | import { HTTPResponse } from 'puppeteer'; 4 | import ServiceWorkerGatherer from '../service-worker/service-worker-gatherer.js'; 5 | 6 | class OfflineGatherer extends Gatherer { 7 | meta: LH.Gatherer.GathererMeta = { 8 | supportedModes: ['navigation'], 9 | // @ts-ignore 10 | dependencies: {ServiceWorkerGatherer: ServiceWorkerGatherer.symbol}, 11 | }; 12 | // @ts-ignore 13 | async getArtifact(context: LH.Gatherer.Context) { 14 | const {driver, page, dependencies} = context; 15 | 16 | let response: HTTPResponse | null = null; 17 | if (dependencies['ServiceWorkerGatherer']?.registrations?.length) 18 | try { 19 | const offlinePage = await page.browser().newPage(); 20 | await offlinePage.setOfflineMode(true); 21 | response = await offlinePage.goto(page.url(), { timeout: 2000, waitUntil: 'domcontentloaded'}).then((response) => { 22 | return response; 23 | }).catch((error) => { 24 | return error; 25 | }); 26 | } catch (error) {} 27 | 28 | if (response?.status && response?.statusText) { 29 | return { 30 | status: response?.status(), 31 | fromServiceWorker: response?.fromServiceWorker(), 32 | explanation: response?.statusText(), 33 | } 34 | } 35 | 36 | return { 37 | status: -1, 38 | fromServiceWorker: false, 39 | explanation: 'Timed out waiting for start_url to respond.', 40 | } 41 | } 42 | } 43 | 44 | export default OfflineGatherer; -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/service-worker/service-worker-audit.ts: -------------------------------------------------------------------------------- 1 | import * as LH from 'lighthouse/types/lh.js'; 2 | import { Audit, Artifacts, IcuMessage } from 'lighthouse'; 3 | import * as i18n from 'lighthouse/core/lib/i18n/i18n.js'; 4 | 5 | const UIStrings = { 6 | title: 'Registers a service worker that controls page and `start_url`', 7 | failureTitle: 'Does not register a service worker that controls page and `start_url`', 8 | description: 'The service worker is the technology that enables your app to use many ' + 9 | 'Progressive Web App features, such as offline, add to homescreen, and push ' + 10 | 'notifications. [Learn more about Service Workers](https://developer.chrome.com/docs/lighthouse/pwa/service-worker/).', 11 | explanationOutOfScope: 'This origin has one or more service workers, however the page ' + 12 | '({pageUrl}) is not in scope.', 13 | explanationNoManifest: 'This page is controlled by a service worker, however ' + 14 | 'no `start_url` was found because no manifest was fetched.', 15 | explanationBadManifest: 'This page is controlled by a service worker, however ' + 16 | 'no `start_url` was found because manifest failed to parse as valid JSON', 17 | explanationBadStartUrl: 'This page is controlled by a service worker, however ' + 18 | 'the `start_url` ({startUrl}) is not in the service worker\'s scope ({scopeUrl})', 19 | }; 20 | 21 | const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); 22 | 23 | interface ServiceWorkerUrls { 24 | scopeUrl: string; 25 | scriptUrl: string; 26 | } 27 | 28 | class ServiceWorkerAudit extends Audit { 29 | static get meta(): LH.Audit.Meta { 30 | return { 31 | id: 'service-worker-audit', 32 | title: str_(UIStrings.title), 33 | failureTitle: str_(UIStrings.failureTitle), 34 | description: str_(UIStrings.description), 35 | supportedModes: ['navigation'], 36 | // @ts-ignore 37 | requiredArtifacts: ['URL', 'ServiceWorkerGatherer', 'WebAppManifest'], 38 | }; 39 | } 40 | 41 | static getVersionsForOrigin( 42 | versions: LH.Crdp.ServiceWorker.ServiceWorkerVersion[], 43 | pageUrl: URL 44 | ): LH.Crdp.ServiceWorker.ServiceWorkerVersion[] { 45 | return versions 46 | .filter(v => v.status === 'activated' /*|| v.status === 'installing'*/) 47 | .filter(v => new URL(v.scriptURL).origin === pageUrl.origin); 48 | } 49 | 50 | static getControllingServiceWorker( 51 | matchingSWVersions: LH.Crdp.ServiceWorker.ServiceWorkerVersion[], 52 | registrations: LH.Crdp.ServiceWorker.ServiceWorkerRegistration[], 53 | pageUrl: URL 54 | ): ServiceWorkerUrls | undefined { 55 | const scriptByScopeUrlList: ServiceWorkerUrls[] = []; 56 | 57 | for (const version of matchingSWVersions) { 58 | const matchedRegistration = registrations 59 | .find(r => r.registrationId === version.registrationId); 60 | 61 | if (matchedRegistration) { 62 | const scopeUrl = new URL(matchedRegistration.scopeURL).href; 63 | const scriptUrl = new URL(version.scriptURL).href; 64 | scriptByScopeUrlList.push({scopeUrl, scriptUrl}); 65 | } 66 | } 67 | 68 | const pageControllingUrls = scriptByScopeUrlList 69 | .filter(ss => pageUrl.href.startsWith(ss.scopeUrl)) 70 | .sort((ssA, ssB) => ssA.scopeUrl.length - ssB.scopeUrl.length) 71 | .pop(); 72 | 73 | return pageControllingUrls; 74 | } 75 | 76 | static checkStartUrl( 77 | WebAppManifest: Artifacts['WebAppManifest'], 78 | scopeUrl: string 79 | ): IcuMessage | undefined { 80 | if (!WebAppManifest) { 81 | return str_(UIStrings.explanationNoManifest); 82 | } 83 | if (!WebAppManifest.value) { 84 | return str_(UIStrings.explanationBadManifest); 85 | } 86 | 87 | const startUrl = WebAppManifest.value.start_url.value; 88 | if (!startUrl.startsWith(scopeUrl)) { 89 | return str_(UIStrings.explanationBadStartUrl, {startUrl, scopeUrl}); 90 | } 91 | } 92 | 93 | static audit(artifacts: Artifacts): LH.Audit.Product { 94 | const { mainDocumentUrl } = artifacts.URL; 95 | if (!mainDocumentUrl) throw new Error('mainDocumentUrl must exist in navigation mode'); 96 | const pageUrl = new URL(mainDocumentUrl); 97 | // @ts-ignore 98 | const {versions, registrations } = artifacts.ServiceWorkerGatherer as IServiceWorkerGatherer; 99 | 100 | const versionsForOrigin = ServiceWorkerAudit.getVersionsForOrigin(versions, pageUrl); 101 | if (versionsForOrigin.length === 0) { 102 | return { 103 | score: 0, 104 | }; 105 | } 106 | 107 | const serviceWorkerUrls = ServiceWorkerAudit.getControllingServiceWorker(versionsForOrigin, 108 | registrations, pageUrl); 109 | if (!serviceWorkerUrls) { 110 | return { 111 | score: 0, 112 | explanation: str_(UIStrings.explanationOutOfScope, {pageUrl: pageUrl.href}), 113 | }; 114 | } 115 | 116 | const {scriptUrl, scopeUrl} = serviceWorkerUrls; 117 | const details: LH.Audit.Details.DebugData = { 118 | type: 'debugdata', 119 | scriptUrl, 120 | scopeUrl, 121 | }; 122 | 123 | const startUrlFailure = ServiceWorkerAudit.checkStartUrl(artifacts.WebAppManifest, 124 | serviceWorkerUrls.scopeUrl); 125 | if (startUrlFailure) { 126 | return { 127 | score: 0, 128 | details, 129 | explanation: startUrlFailure, 130 | }; 131 | } 132 | 133 | return { 134 | score: 1, 135 | details, 136 | }; 137 | } 138 | } 139 | 140 | export default ServiceWorkerAudit; 141 | export { UIStrings }; -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/service-worker/service-worker-driver.ts: -------------------------------------------------------------------------------- 1 | import * as LH from 'lighthouse/types/lh.js'; 2 | 3 | type ProtocolSession = LH.Gatherer.ProtocolSession; 4 | 5 | type WorkerVersionUpdatedEvent = LH.Crdp.ServiceWorker.WorkerVersionUpdatedEvent; 6 | type WorkerRegistrationUpdatedEvent = LH.Crdp.ServiceWorker.WorkerRegistrationUpdatedEvent; 7 | 8 | async function getServiceWorkerVersions(session: ProtocolSession): Promise { 9 | return new Promise((resolve, reject) => { 10 | const versionUpdatedListener = (data: WorkerVersionUpdatedEvent) => { 11 | // find a service worker with runningStatus that looks like active 12 | // on slow connections the serviceworker might still be installing 13 | const activateCandidates = data.versions.filter(sw => sw.status !== 'redundant'); 14 | const hasActiveServiceWorker = activateCandidates.find(sw => sw.status === 'activated' /*|| sw.status === 'installing'*/); 15 | 16 | if (!activateCandidates.length || hasActiveServiceWorker) { 17 | session.off('ServiceWorker.workerVersionUpdated', versionUpdatedListener); 18 | session.sendCommand('ServiceWorker.disable').then(_ => resolve(data), reject); 19 | } 20 | }; 21 | 22 | session.on('ServiceWorker.workerVersionUpdated', versionUpdatedListener); 23 | session.sendCommand('ServiceWorker.enable').catch(reject); 24 | }); 25 | } 26 | 27 | async function getServiceWorkerRegistrations(session: ProtocolSession): Promise { 28 | return new Promise((resolve, reject) => { 29 | session.once('ServiceWorker.workerRegistrationUpdated', data => { 30 | session.sendCommand('ServiceWorker.disable').then(_ => resolve(data), reject); 31 | }); 32 | session.sendCommand('ServiceWorker.enable').catch(reject); 33 | }); 34 | } 35 | 36 | export {getServiceWorkerVersions, getServiceWorkerRegistrations}; 37 | -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/service-worker/service-worker-gatherer.ts: -------------------------------------------------------------------------------- 1 | import BaseGatherer from 'lighthouse/core/gather/base-gatherer.js'; 2 | import * as serviceWorkers from './service-worker-driver.js'; 3 | import * as LH from 'lighthouse/types/lh.js'; 4 | 5 | 6 | export interface IServiceWorkerGatherer { 7 | versions: any[]; 8 | registrations: any[]; 9 | } 10 | 11 | class ServiceWorkerGatherer extends BaseGatherer { 12 | static symbol = Symbol('ServiceWorkerGatherer'); 13 | 14 | meta: LH.Gatherer.GathererMeta = { 15 | symbol: ServiceWorkerGatherer.symbol, 16 | supportedModes: ['navigation'], 17 | } 18 | 19 | async getArtifact(context: LH.Gatherer.Context): Promise { 20 | const session = context.driver.defaultSession; 21 | const {versions} = await serviceWorkers.getServiceWorkerVersions(session); 22 | const {registrations} = await serviceWorkers.getServiceWorkerRegistrations(session); 23 | 24 | return { 25 | versions, 26 | registrations, 27 | }; 28 | } 29 | } 30 | 31 | export default ServiceWorkerGatherer; 32 | -------------------------------------------------------------------------------- /Report/lighthouse/custom-audits/types.ts: -------------------------------------------------------------------------------- 1 | import * as LH from 'lighthouse/types/lh.js'; 2 | 3 | interface CustomArtifacts extends LH.Artifacts { 4 | ServiceWorkerGatherer: any, 5 | OfflineGatherer: any 6 | } 7 | 8 | export default CustomArtifacts; -------------------------------------------------------------------------------- /Report/lighthouse/custom-settings.ts: -------------------------------------------------------------------------------- 1 | const defaultSettings = { 2 | output: 'json', 3 | maxWaitForFcp: 10 * 1000, 4 | maxWaitForLoad: 15 * 1000, 5 | pauseAfterFcpMs: 1, 6 | pauseAfterLoadMs: 1, 7 | networkQuietThresholdMs: 1, 8 | cpuQuietThresholdMs: 1, 9 | 10 | formFactor: 'desktop', 11 | throttling: { 12 | rttMs: 0, 13 | throughputKbps: 0, 14 | requestLatencyMs: 0, 15 | downloadThroughputKbps: 0, 16 | uploadThroughputKbps: 0, 17 | cpuSlowdownMultiplier: 1 18 | }, 19 | throttlingMethod: 'provided', 20 | 21 | auditMode: false, 22 | gatherMode: false, 23 | disableStorageReset: true, 24 | debugNavigation: false, 25 | channel: 'node', 26 | usePassiveGathering: false, 27 | disableFullPageScreenshot: true, 28 | skipAboutBlank: true, 29 | blankPage: 'about:blank', 30 | 31 | // the following settings have no defaults but we still want ensure that `key in settings` 32 | // in config will work in a typechecked way 33 | budgets: null, 34 | locale: 'en-US', // actual default determined by Config using lib/i18n 35 | blockedUrlPatterns: null, 36 | additionalTraceCategories: null, 37 | extraHeaders: null, 38 | precomputedLanternData: null, 39 | onlyAudits: null, 40 | onlyCategories: null, 41 | skipAudits: null, 42 | }; 43 | 44 | export default defaultSettings; -------------------------------------------------------------------------------- /Report/lighthouse/lighthouse.ts: -------------------------------------------------------------------------------- 1 | 2 | import puppeteer from 'puppeteer'; 3 | import lighthouse, { OutputMode, Flags } from 'lighthouse'; 4 | import customConfig from './custom-config.js'; 5 | import { screenEmulationMetrics, userAgents} from 'lighthouse/core/config/constants.js'; 6 | 7 | const MAX_WAIT_FOR_LOAD = 30 * 1000; //seconds 8 | const MAX_WAIT_FOR_FCP = 15 * 1000; //seconds 9 | const SKIP_RESOURCES = ['stylesheet', 'font', 'image', 'imageset', 'media', 'ping', 'fetch', 'prefetch', 'preflight', 'websocket']; 10 | const base64Font = "d09GMgABAAAAAANMAA4AAAAAB5wAAALzAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cGh4GYACCQggEEQgKghCCBQsKAAE2AiQDEAQgBYVoBzQbkgZRlA3KLrKPwziGXKIIJZRhWeKjhaW5iHc8D+337dyZeX8XM0tqCa+b0BAJWSUTRSOESKRDer+Elf/fP73/pxBepXCrtIUq9XPyX1N40yxPPd20eYM4w2v+53J6YxJ8phOZw26LHd01NmV5hIHdyxbInCB5FA2xiENuQG8T/JF1eSoE/n6IGgX8f9FGc7MmMBOLgSKqKhoKWu3zVEd5QP7CQyCTf/aFhOLqKm7y8u2cEtgYGqpXzVU09aPXCADWIlBRsRIBjmAIVrKwFmiaMDQ1Rb55aYUAGRBACgrUjxPIF1pXmgBkagoJGhhFh9PAGAANu5GgtjZr1jp7y1ntpn03PdHSu3fWeb7fd3jZdXHg6Fi5vtfD40x2dqa4UHgNLM12evZ7ep7Lz1+Vl0rvQe/RrrmQEdv3PdpUbTJdpCgwMiBDEJDplewVmCkIzPBaDzKQjAbPScwrvD6WxcLy2JLoK7I0sjxKzH83T33Em91k3YXaZmYDE4qJ3cwpyMuWhz7FvwN8C1bygnWDN+68VXfHso7XwgdhFev4iHCQcRrPMxMI3zf5HHM+8jdqX4cdfiSYel/7uhbAqAKik9qQsrf2rG0YeNaFS/KfEzSHMUDBdNMxhQnAfA8COKQGAWLUTwHFiD8CqlG9gGY0RUBnRjYAAQZGcxIIMN2OzAiYYUH+VWnTcB/YEHPNQDHbb6jmOkcb+QmdxbGHgblhwXQ3c2kfM2zOgdMoBk2MKpWrgCHRssRAuNi4eJAiTRAqRXGlOoVqIIXqYRWhr6Mh6ZAylDpYqRo1KEzFKLXgD0bjrBj0vX/aKqycRs3057/ijkScqbtDWS5EDQe4qlS5ejUKGcFL3DpN3nlw+U2ljGiV0dUIBxMbnP/65zDZWdHzZl32WKFIt6TOPgrNpBwXhpVByhhRaiG5RSWIIfyeKqWKYUzw8CBNm8q5GJedGJ3catUXCj8iwF0YZpCIhdUKyoa+RR0PpgCmT7gFDVFSpt2qpRLWXwAAAA=="; 11 | const base64Image = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=="; 12 | 13 | const audit = async (page: any, url: string, desktop?: boolean) => { 14 | 15 | // Puppeteer with Lighthouse 16 | const flags = { 17 | logLevel: 'silent', // 'silent' | 'error' | 'info' | 'verbose' 18 | output: 'json', // 'json' | 'html' | 'csv' 19 | locale: 'en-US', 20 | 21 | maxWaitForFcp: MAX_WAIT_FOR_FCP, 22 | maxWaitForLoad: MAX_WAIT_FOR_LOAD, 23 | 24 | pauseAfterLoadMs: 250, 25 | pauseAfterFcpMs: 250, 26 | pauseAfterNetworkQuietMs: 250, 27 | pauseAfterCPUIdleMs: 250, 28 | networkQuietThresholdMs: 250, 29 | cpuQuietThresholdMs: 250, 30 | 31 | throttling: { 32 | rttMs: 0, 33 | throughputKbps: 0, 34 | requestLatencyMs: 0, 35 | downloadThroughputKbps: 0, 36 | uploadThroughputKbps: 0, 37 | cpuSlowdownMultiplier: 0 38 | }, 39 | 40 | disableStorageReset: true, 41 | disableFullPageScreenshot: true, 42 | 43 | skipAboutBlank: true, 44 | formFactor: desktop ? 'desktop' : 'mobile', 45 | screenEmulation: screenEmulationMetrics[desktop? 'desktop': 'mobile'], 46 | emulatedUserAgent: `${desktop ? userAgents.desktop : userAgents.mobile} PWABuilderHttpAgent`, 47 | onlyAudits: ['installable-manifest', 'is-on-https', 'service-worker-audit', 'https-audit', 'offline-audit'], //'maskable-icon', 'service-worker', 'themed-omnibox', 'viewport', 'apple-touch-icon', 'splash-screen' 48 | } as Flags; 49 | 50 | 51 | try { 52 | // @ts-ignore 53 | const rawResult = await lighthouse(url, flags, customConfig, page); 54 | return { 55 | audits: rawResult?.lhr?.audits, 56 | artifacts: { 57 | Manifest: { 58 | url: rawResult?.artifacts.WebAppManifest?.url, 59 | raw: rawResult?.artifacts.WebAppManifest?.raw 60 | }, 61 | // @ts-ignore 62 | ServiceWorker: rawResult?.artifacts.ServiceWorkerGatherer 63 | } 64 | }; 65 | } 66 | catch (error) { 67 | writeAndExit(error, 1); 68 | } 69 | }; 70 | 71 | const writeAndExit = (data: any, code: 1 | 0) => { 72 | if (process.stdout) { 73 | if (code){ 74 | process.stdout.write(JSON.stringify(data, Object.getOwnPropertyNames(data)), () => { 75 | process.exit(code) 76 | }); 77 | } 78 | else { 79 | process.stdout.write(JSON.stringify(data), () => { 80 | process.exit(code); 81 | }); 82 | } 83 | } 84 | else 85 | process.exit(code); 86 | } 87 | 88 | // adding puppeter's like flags https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/node/ChromeLauncher.ts 89 | // on to op chrome-launcher https://github.com/GoogleChrome/chrome-launcher/blob/main/src/flags.ts#L13 90 | 91 | const disabledFeatures = [ 92 | 'Translate', 93 | 'TranslateUI', 94 | // AcceptCHFrame disabled because of crbug.com/1348106. 95 | 'AcceptCHFrame', 96 | 'AutofillServerCommunication', 97 | 'CalculateNativeWinOcclusion', 98 | 'CertificateTransparencyComponentUpdater', 99 | 'InterestFeedContentSuggestions', 100 | 'MediaRouter', 101 | 'DialMediaRouteProvider', 102 | // 'OptimizationHints' 103 | ]; 104 | 105 | async function execute() { 106 | const url = process.argv[2]; 107 | const desktop = process.argv[3] === 'desktop'; 108 | 109 | const currentBrowser = await puppeteer.launch({ 110 | args: [ 111 | '--no-sandbox', 112 | '--no-pings', 113 | '--deny-permission-prompts', 114 | '--disable-domain-reliability', 115 | '--disabe-gpu', 116 | '--block-new-web-contents', 117 | `--disable-features=${disabledFeatures.join(',')}`, 118 | // '--single-process' 119 | ], 120 | headless: true, 121 | defaultViewport: { 122 | width: screenEmulationMetrics[desktop? 'desktop': 'mobile'].width, 123 | height: screenEmulationMetrics[desktop? 'desktop': 'mobile'].height, 124 | deviceScaleFactor: screenEmulationMetrics[desktop? 'desktop': 'mobile'].deviceScaleFactor, 125 | isMobile: desktop? false: true, 126 | hasTouch: desktop? false: true, 127 | isLandscape: desktop? true: false, 128 | }, 129 | }); 130 | 131 | const page = await currentBrowser.pages().then(pages => pages[0]); 132 | await page.setBypassServiceWorker(true); 133 | await page.setRequestInterception(true); 134 | 135 | page.on('dialog', dialog => { 136 | dialog.dismiss(); 137 | }); 138 | 139 | page.on('request', (req) => { 140 | // commented because it doesn't work on Azure environment 141 | // const resourceType = req.resourceType(); 142 | // if (SKIP_RESOURCES.some((type) => resourceType == type)){ 143 | // let contentType = req.headers().accept?.split(',')[0] || 'text/plain'; 144 | // switch (resourceType) { 145 | // case 'image': 146 | // req.respond( 147 | // { 148 | // status: 200, 149 | // contentType, 150 | // body: Buffer.from(base64Image, 'base64'), 151 | // }); 152 | // break; 153 | // case 'font': 154 | // req.respond( 155 | // { 156 | // status: 200, 157 | // contentType, 158 | // body: Buffer.from(base64Font, 'base64'), 159 | 160 | // }); 161 | // break; 162 | // case 'fetch': 163 | // if (req.method() == 'GET') 164 | // req.respond({ 165 | // status: 200, 166 | // contentType, 167 | // body: JSON.stringify({ success: true, message: "Intercepted fetch request" }), 168 | // }); 169 | // else 170 | // req.continue(); 171 | // break; 172 | // default: 173 | // req.respond({ 174 | // status: 200, 175 | // contentType, 176 | // body: '{"success": true}', 177 | // }); 178 | // break; 179 | // } 180 | // } 181 | // else 182 | req.continue(); 183 | }); 184 | 185 | const manifest_alt = { 186 | url: '', 187 | raw: '', 188 | json: {}, 189 | }; 190 | page.on('response', async res => { 191 | if (res.request().resourceType() === 'manifest') { 192 | // manifest_alt.json = await res.json(); 193 | try { 194 | manifest_alt.raw = await res.text(); 195 | manifest_alt.url = res.url(); 196 | }catch (error) {} 197 | } 198 | return true; 199 | }); 200 | 201 | // don't let the bad SW kill the audit 202 | let valveTriggered = false; 203 | const turnValve = setTimeout(async () => { 204 | valveTriggered = true; 205 | try { 206 | const client = await page.target().createCDPSession(); 207 | await client.send('ServiceWorker.enable'); 208 | await client.send('ServiceWorker.stopAllWorkers'); 209 | } catch (error) { 210 | console.log(error); 211 | } 212 | }, MAX_WAIT_FOR_LOAD * 2); 213 | 214 | try { 215 | // run lighthouse audit 216 | const webAppReport = await audit(page, url, desktop); 217 | clearTimeout(turnValve); 218 | if (valveTriggered && webAppReport?.audits!['service-worker-audit']) { 219 | // @ts-ignore 220 | webAppReport.audits['service-worker-audit'].details = { 221 | error: 'Service worker timed out', 222 | }; 223 | } 224 | if (manifest_alt.url && webAppReport && manifest_alt.raw && (!webAppReport?.artifacts?.Manifest?.raw || manifest_alt.raw > webAppReport.artifacts.Manifest.raw)) { 225 | webAppReport.artifacts.Manifest = manifest_alt; 226 | } 227 | 228 | await currentBrowser.close(); 229 | 230 | writeAndExit(webAppReport, 0); 231 | } catch (error: any) { 232 | await currentBrowser.close(); 233 | 234 | writeAndExit(error, 1); 235 | } 236 | }; 237 | 238 | await execute(); -------------------------------------------------------------------------------- /Report/type.ts: -------------------------------------------------------------------------------- 1 | export type Report = { 2 | audits?: { 3 | isOnHttps: { 4 | score: boolean; 5 | }; 6 | noMixedContent: { 7 | score: boolean; 8 | }; 9 | installableManifest: { 10 | score: boolean; 11 | details: { 12 | url?: string; 13 | }; 14 | }; 15 | serviceWorker: { 16 | score: boolean; 17 | details: { 18 | url?: string; 19 | scope?: string; 20 | features?: { 21 | [key: string]: any; 22 | raw?: undefined; 23 | } | undefined; 24 | }; 25 | }; 26 | offlineSupport: { 27 | score: boolean; 28 | }; 29 | // maskableIcon: { 30 | // score: boolean; 31 | // }; 32 | // splashScreen: { 33 | // score: boolean; 34 | // }; 35 | // themedOmnibox: { 36 | // score: boolean; 37 | // }; 38 | // viewport: { 39 | // score: boolean; 40 | // }; 41 | }; 42 | artifacts?: { 43 | webAppManifest?: { 44 | raw?: string, 45 | url?: string, 46 | json?: unknown 47 | }; 48 | serviceWorker?: { 49 | raw?: string[], 50 | url?: string, 51 | }; 52 | }; 53 | error?: string; 54 | }; -------------------------------------------------------------------------------- /Security/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Security/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /Security/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import { checkParams } from '../utils/checkParams.js'; 3 | import loadPage, { LoadedPage, closeBrowser } from '../utils/loadPage.js'; 4 | import { logHttpsResult } from '../utils/urlLogger.js'; 5 | 6 | const httpTrigger: AzureFunction = async function ( 7 | context: Context, 8 | req: HttpRequest 9 | ): Promise { 10 | const checkResult = checkParams(req, ['site']); 11 | if (checkResult.status !== 200) { 12 | context.res = checkResult; 13 | context.log.error(`Security: ${checkResult.body?.error.message}`); 14 | return; 15 | } 16 | 17 | context.log.info( 18 | `Security function is processing a request for site: ${req.query.site}` 19 | ); 20 | 21 | const site = req?.query?.site as string; 22 | let siteData: LoadedPage | undefined; 23 | const startTime = new Date(); 24 | 25 | try { 26 | let page; 27 | let pageResponse; 28 | 29 | if (!site) throw new Error('Exception: no site URL'); 30 | 31 | try { 32 | const response = await loadPage(site, context); 33 | 34 | if (!(response instanceof Error)) { 35 | siteData = response; 36 | } else { 37 | context.log.info(response); 38 | context.log.info(response.message); 39 | } 40 | 41 | page = siteData?.sitePage; 42 | pageResponse = siteData?.pageResponse; 43 | 44 | if (!page) { 45 | throw new Error(''); 46 | } 47 | 48 | await page.setRequestInterception(true); 49 | 50 | const whiteList = ['document', 'plain', 'script', 'javascript']; 51 | page.on('request', req => { 52 | const type = req.resourceType(); 53 | if (whiteList.some(el => type.indexOf(el) >= 0)) { 54 | req.continue(); 55 | } else { 56 | req.abort(); 57 | } 58 | }); 59 | } catch (err: unknown) { 60 | if (siteData && siteData.browser) { 61 | await closeBrowser(context, siteData.browser); 62 | } 63 | 64 | context.res = { 65 | status: 500, 66 | body: { 67 | error: { 68 | error: err, 69 | message: 70 | err instanceof Error && err.message ? err.message : 'noMessage', 71 | }, 72 | }, 73 | }; 74 | 75 | context.log.error( 76 | `Security function ERRORED loading a request for site: ${req.query.site}` 77 | ); 78 | logHttpsResult( 79 | site, 80 | false, 81 | 0, 82 | 'Error loading site data: ' + err, 83 | startTime 84 | ); 85 | } 86 | 87 | const securityDetails = pageResponse?.securityDetails(); 88 | 89 | if (securityDetails) { 90 | const protocol = securityDetails.protocol().replace('_', ''); 91 | const results = { 92 | isHTTPS: site.includes('https'), 93 | validProtocol: 94 | protocol === 'TLS 1.3' || 95 | protocol === 'TLS 1.2' || 96 | protocol === 'QUIC', 97 | protocol, 98 | valid: securityDetails.validTo() <= new Date().getTime(), 99 | }; 100 | 101 | if (siteData && siteData.browser) { 102 | await closeBrowser(context, siteData.browser); 103 | } 104 | 105 | context.res = { 106 | status: 200, 107 | body: { 108 | data: results, 109 | }, 110 | }; 111 | 112 | const score = [ 113 | { metric: results.isHTTPS, score: 10 }, 114 | { metric: results.valid, score: 5 }, 115 | { metric: results.validProtocol, score: 5 }, 116 | ] 117 | .filter(a => a.metric) 118 | .map(a => a.score) 119 | .reduce((a, b) => a + b); 120 | logHttpsResult( 121 | site, 122 | results.isHTTPS && results.valid && results.validProtocol, 123 | score, 124 | null, 125 | startTime 126 | ); 127 | } else { 128 | if (siteData && siteData.browser) { 129 | await closeBrowser(context, siteData.browser); 130 | } 131 | 132 | context.res = { 133 | status: 400, 134 | body: { 135 | error: 'Security Details could not be retrieved from the site', 136 | }, 137 | }; 138 | 139 | const errorMessage = `Security function could not load security details for site: ${req.query.site}`; 140 | context.log.error(errorMessage); 141 | logHttpsResult(site, false, 0, errorMessage, startTime); 142 | } 143 | } catch (err: unknown) { 144 | if (siteData && siteData.browser) { 145 | await closeBrowser(context, siteData.browser); 146 | } 147 | 148 | context.res = { 149 | status: 500, 150 | body: { 151 | error: { 152 | error: err, 153 | message: 154 | err instanceof Error && err.message ? err.message : 'noMessage', 155 | }, 156 | }, 157 | }; 158 | const errorMessage = `Security function ERRORED loading a request for site: ${ 159 | req.query.site 160 | } with error: ${ 161 | err instanceof Error && err.message ? err.message : 'noMessage' 162 | }`; 163 | context.log.error(errorMessage); 164 | logHttpsResult(site, false, 0, errorMessage, startTime); 165 | } 166 | }; 167 | 168 | export default httpTrigger; 169 | 170 | /** 171 | * @openapi 172 | * /Security: 173 | * get: 174 | * deprecated: true 175 | * summary: Check webapp security 176 | * description: Validate webapp security protocols 177 | * tags: 178 | * - Validate 179 | * parameters: 180 | * - $ref: ?file=components.yaml#/parameters/site 181 | * responses: 182 | * '200': 183 | * $ref: ?file=components.yaml#/responses/security/200 184 | */ 185 | -------------------------------------------------------------------------------- /Security/sample.dat: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure" 3 | } -------------------------------------------------------------------------------- /Site/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Site/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /Site/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | 3 | import getManifestFromFile, { 4 | ifSupportedFile, 5 | } from '../utils/getManifestFromFile.js'; 6 | import { getManifest } from '../utils/getManifest.js'; 7 | 8 | import { ExceptionMessage, ExceptionWrap } from '../utils/Exception.js'; 9 | import { Manifest, ManifestFormat, ManifestInfo } from '../utils/interfaces.js'; 10 | import { checkParams } from '../utils/checkParams.js'; 11 | 12 | import pkg from 'pwabuilder-lib'; 13 | const { manifestTools } = pkg; 14 | 15 | const httpTrigger: AzureFunction = async function ( 16 | context: Context, 17 | req: HttpRequest 18 | ): Promise { 19 | 20 | const checkResult = checkParams(req, ['site']); 21 | if (checkResult.status !== 200){ 22 | context.res = checkResult; 23 | context.log.error(`Site: ${checkResult.body?.error.message}`); 24 | return; 25 | } 26 | 27 | context.log.info( 28 | `Site function is processing a request for site: ${req.query.site}` 29 | ); 30 | 31 | try { 32 | let manifestUrl: string; 33 | let manifest: Manifest | null = null; 34 | const url = req?.query?.site as string; 35 | 36 | // Handle File 37 | if (req.method === 'POST' && ifSupportedFile(req)) { 38 | context.log.info( 39 | `Site function is getting the manifest from a file for site: ${req.query.site}` 40 | ); 41 | 42 | manifest = await getManifestFromFile(req); 43 | } else { 44 | // Handle Site 45 | context.log.info( 46 | `Site function is loading the manifest from the URL for site: ${req.query.site}` 47 | ); 48 | 49 | const manifestData = await getManifest(url, context); 50 | 51 | if (manifestData) { 52 | manifest = manifestData.json; 53 | manifestUrl = manifestData.url; 54 | } 55 | } 56 | 57 | // TODO replace this with the validation tool - utils/schema 58 | const detectedFormat = manifestTools.detect(manifest); 59 | 60 | manifestTools.convertTo( 61 | { format: detectedFormat, content: manifest }, 62 | ManifestFormat.w3c, 63 | async (err: Error, resultManifestInfo: ManifestInfo) => { 64 | if (err) { 65 | context.log.error(err); 66 | context.res = { 67 | status: 400, 68 | body: { 69 | message: 'Failed to convert to a w3c standard format', 70 | }, 71 | }; 72 | return; 73 | } 74 | 75 | manifestTools.validateAndNormalizeStartUrl( 76 | url, 77 | resultManifestInfo, 78 | (err: Error, validatedManifestInfo: ManifestInfo) => { 79 | if (err) { 80 | context.log.error(err); 81 | context.res = { 82 | status: 400, 83 | body: { 84 | message: 'Failed to validate and normalize the manifest', 85 | }, 86 | }; 87 | return; 88 | } 89 | validatedManifestInfo.generatedUrl = manifestUrl; 90 | 91 | context.res = { 92 | body: validatedManifestInfo, 93 | }; 94 | } 95 | ); 96 | } 97 | ); 98 | } catch (exception) { 99 | if (exception instanceof ExceptionWrap) { 100 | context.res = { 101 | status: 400, 102 | body: { 103 | message: ExceptionMessage[exception.type], 104 | }, 105 | }; 106 | 107 | context.log.error( 108 | `Site function errored getting the manifest for site: ${req.query.site} with error: ${exception}` 109 | ); 110 | } else { 111 | context.res = { 112 | status: 400, 113 | }; 114 | 115 | context.log.error( 116 | `Site function errored getting the manifest for site: ${req.query.site}` 117 | ); 118 | } 119 | } 120 | }; 121 | 122 | export default httpTrigger; 123 | 124 | /** 125 | * @openapi 126 | * /Site: 127 | * get: 128 | * deprecated: true 129 | * summary: Custom report 130 | * description: Custom manifest validation 131 | * tags: 132 | * - Report 133 | * parameters: 134 | * - $ref: ?file=components.yaml#/parameters/site 135 | * responses: 136 | * '200': 137 | * description: 'OK' 138 | * content: 139 | * application/json: 140 | * schema: 141 | * type: object 142 | * properties: 143 | * content: 144 | * type: object 145 | * $ref: ?file=manifest.yaml 146 | * format: 147 | * type: string 148 | * generatedUrl: 149 | * type: string 150 | * id: 151 | * type: number 152 | * default: 153 | * type: object 154 | * errors: 155 | * type: array 156 | * items: 157 | * type: object 158 | * suggestions: 159 | * type: array 160 | * items: 161 | * type: object 162 | * warnings: 163 | * type: array 164 | * items: 165 | * type: object 166 | */​ -------------------------------------------------------------------------------- /Swagger/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Swagger/index.js" 20 | } -------------------------------------------------------------------------------- /Swagger/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions" 2 | 3 | import fs from 'fs'; 4 | import { readFile } from 'fs/promises'; 5 | import { dirname } from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | import { absolutePath } from 'swagger-ui-dist' 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url + '/../../')); 10 | const pathToSwaggerUi = absolutePath(); 11 | 12 | const swaggerContent = fs.readFileSync(`${pathToSwaggerUi}/swagger-initializer.js`) 13 | .toString() 14 | .replace("https://petstore.swagger.io/v2/swagger.json", "?file=open-api.yaml"); 15 | 16 | const indexContent = fs.readFileSync(`${pathToSwaggerUi}/index.html`) 17 | .toString() 18 | .replaceAll('"./', '"?file=').replace("index.css", "?file=index.css"); 19 | 20 | 21 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 22 | let file=`index.html` 23 | let specsFolder = false; 24 | 25 | if (req.query.file){ 26 | file = req.query.file; 27 | file = file.replace(/\//g, ''); 28 | } 29 | else { 30 | context.res = { 31 | status: 200, 32 | body: indexContent, 33 | headers: { 34 | 'Content-Type': 'text/html' 35 | } 36 | }; 37 | return; 38 | } 39 | 40 | if (file.endsWith('.yaml')) { 41 | specsFolder = true; 42 | } 43 | else if (file == 'swagger-initializer.js') { 44 | context.res = { 45 | status: 200, 46 | body: swaggerContent, 47 | headers: { 48 | 'Content-Type': 'application/javascript' 49 | } 50 | }; 51 | return; 52 | } 53 | 54 | const filePath = (specsFolder ? `${__dirname}/.openAPI/` : `${pathToSwaggerUi}/`) + file; 55 | 56 | let content; 57 | try { 58 | content = await readFile(filePath); 59 | } catch (error) {} 60 | 61 | if (content) { 62 | context.res = { 63 | status: 200, 64 | body: content, 65 | isRaw: true 66 | }; 67 | } 68 | else { 69 | context.log.warn(`Swagger: file not found: ${filePath}`); 70 | 71 | context.res = { 72 | status: 404, 73 | body: "Not Found" 74 | }; 75 | } 76 | }; 77 | 78 | export default httpTrigger; 79 | 80 | -------------------------------------------------------------------------------- /WebManifest/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/WebManifest/index.js" 20 | } -------------------------------------------------------------------------------- /WebManifest/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import { checkParams, checkBody } from '../utils/checkParams.js'; 3 | import { getManifest } from '../utils/getManifest.js'; 4 | import testManifest from '../utils/testManifest.js'; 5 | 6 | const httpTrigger: AzureFunction = async function ( 7 | context: Context, 8 | req: HttpRequest 9 | ): Promise { 10 | 11 | const checkSite = checkParams(req, ['site']); 12 | const checkBodyManifest = checkBody(req, ['manifest']); 13 | if (checkSite.status !== 200 && checkBodyManifest.status !== 200){ 14 | const _problem = checkSite 15 | _problem.body?.error.message && (_problem.body.error.message = [checkBodyManifest.body?.error.message as string, checkSite.body?.error.message as string]) 16 | context.res = _problem; 17 | context.log.error(`WebManifest: ${checkSite.body?.error.message} or ${checkBodyManifest.body?.error.message}`); 18 | return; 19 | } 20 | 21 | 22 | context.log( 23 | `Web Manifest function is processing a request for site: ${req?.query?.site}` 24 | ); 25 | 26 | const site = req?.query?.site; 27 | const maniObject = req?.body?.manifest; 28 | const maniUrl = req?.body?.maniurl; 29 | 30 | try { 31 | if (maniObject && (maniUrl || site)) { 32 | context.log.info( 33 | `Web Manifest function has a raw manifest object for site: ${req?.query?.site}` 34 | ); 35 | 36 | const results = await testManifest(maniObject); 37 | 38 | context.res = { 39 | status: 200, 40 | body: { 41 | data: results, 42 | content: { 43 | json: maniObject, 44 | url: maniUrl || site, 45 | }, 46 | }, 47 | }; 48 | 49 | context.log.info( 50 | `Web Manifest function is DONE processing for site: ${req.query.site}` 51 | ); 52 | } else if (site) { 53 | context.log.info( 54 | `Web Manifest function is grabbing manifest object for site: ${req.query.site}` 55 | ); 56 | const maniData = await getManifest(site, context); 57 | 58 | if (maniData?.json) { 59 | const results = await testManifest(maniData?.json); 60 | 61 | context.res = { 62 | status: 200, 63 | body: { 64 | data: results, 65 | content: maniData, 66 | }, 67 | }; 68 | 69 | context.log.info( 70 | `Web Manifest function is DONE processing for site: ${req.query.site}` 71 | ); 72 | } 73 | } 74 | } catch (err: any) { 75 | context.res = { 76 | status: 400, 77 | body: { 78 | error: { error: err, message: err.message }, 79 | }, 80 | }; 81 | 82 | context.log.error( 83 | `Web Manifest function has ERRORED while processing for site: ${req.query.site} with this error: ${err.message}` 84 | ); 85 | } 86 | }; 87 | 88 | export default httpTrigger; 89 | 90 | /** 91 | * @openapi 92 | * /WebManifest: 93 | * post: 94 | * deprecated: true 95 | * summary: Check webmanifest 96 | * description: Validate webapp manifest 97 | * tags: 98 | * - Validate 99 | * parameters: 100 | * - name: site 101 | * schema: 102 | * type: string 103 | * default: https://webboard.app 104 | * in: query 105 | * description: Web application URL 106 | * requestBody: 107 | * description: Optional body params 108 | * content: 109 | * application/json: 110 | * schema: 111 | * type: object 112 | * properties: 113 | * manifest: 114 | * type: object 115 | * default: null 116 | * maniurl: 117 | * type: string 118 | * default: null 119 | * responses: 120 | * '200': 121 | * $ref: ?file=components.yaml#/responses/manifestGrab/200 122 | */​ -------------------------------------------------------------------------------- /deprecated/Hint/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Hint/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /deprecated/Hint/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | 3 | import { Analyzer } from 'hint'; 4 | import { UserConfig, AnalyzerResult } from 'hint'; 5 | 6 | import { checkParams } from '../../utils/checkParams.js'; 7 | 8 | const httpTrigger: AzureFunction = async function ( 9 | context: Context, 10 | req: HttpRequest 11 | ): Promise { 12 | 13 | const checkResult = checkParams(req, ['site']); 14 | if (checkResult.status !== 200){ 15 | context.res = checkResult; 16 | context.log.error(`Hint: ${checkResult.body?.error.message}`); 17 | return; 18 | } 19 | 20 | context.log.info( 21 | `Service Worker function is processing a request for site: ${req.query.site}` 22 | ); 23 | 24 | const url = req?.query?.site as string; 25 | 26 | // const currentBrowser = await getBrowser(context); 27 | 28 | try { 29 | // run hint 30 | 31 | const swInfo = await hint(url); 32 | 33 | // await closeBrowser(context, currentBrowser); 34 | 35 | context.res = { 36 | status: 200, 37 | body: { 38 | data: swInfo, 39 | }, 40 | }; 41 | 42 | context.log.info( 43 | `Hint function is DONE processing a request for site: ${req.query.site}` 44 | ); 45 | 46 | } catch (error: any) { 47 | // await closeBrowser(context, currentBrowser); 48 | console.warn(JSON.stringify(error)) 49 | context.res = { 50 | status: 500, 51 | body: { 52 | error: error?.message || error, 53 | }, 54 | }; 55 | 56 | if (error.name && error.name.indexOf('TimeoutError') > -1) { 57 | context.log.error( 58 | `Hint function TIMED OUT processing a request for site: ${url}` 59 | ); 60 | } else { 61 | context.log.error( 62 | `Hint function failed for ${url} with the following error: ${error}` 63 | ); 64 | } 65 | } 66 | }; 67 | 68 | const hint = async (url: string) => { 69 | 70 | const userConfig: UserConfig = { 71 | // extends: ['progressive-web-apps'], 72 | hints: { 73 | // "https-only": "information", 74 | "manifest-exists": "information", 75 | "manifest-is-valid": "information", 76 | "manifest-file-extension": "information", 77 | // "meta-theme-color": "information", 78 | "meta-viewport": "information", 79 | "manifest-app-name": "information", 80 | "apple-touch-icons": "information" 81 | }, 82 | connector: { 83 | name: "puppeteer", 84 | options: { 85 | // @ts-ignore 86 | browser: "Chrome", 87 | headless: true, 88 | // ignoreHTTPSErrors: true|false, 89 | puppeteerOptions: { 90 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 91 | }, 92 | // waitUntil: "dom|loaded|networkidle0|networkidle2" 93 | } 94 | }, 95 | hintsTimeout: 30 * 1000, 96 | formatters: [ 97 | 'json' 98 | ] 99 | }; 100 | 101 | const webhint = Analyzer.create(userConfig); 102 | const results: AnalyzerResult[] = await webhint.analyze(url); 103 | 104 | if (results) { 105 | 106 | return results; 107 | } else { 108 | return null; 109 | } 110 | }; 111 | 112 | export default httpTrigger; 113 | 114 | /** 115 | * @openapi 116 | * /Hint: 117 | * get: 118 | * summary: Hint report 119 | * description: Generate PWA-related Hint report for webapp 120 | * tags: 121 | * - Report 122 | * parameters: 123 | * - $ref: ?file=components.yaml#/parameters/site 124 | * responses: 125 | * '200': 126 | * description: OK 127 | */​ 128 | -------------------------------------------------------------------------------- /deprecated/ImageBase64/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "dataType": "binary", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": ["get", "post"] 10 | }, 11 | { 12 | "type": "http", 13 | "direction": "out", 14 | "name": "res" 15 | } 16 | ], 17 | "scriptFile": "../dist/ImageBase64/index.js" 18 | } 19 | -------------------------------------------------------------------------------- /deprecated/ImageBase64/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import * as Jimp from 'jimp'; 3 | 4 | import JSZip from 'jszip'; 5 | import { 6 | generateAllImages, 7 | getBase64Images, 8 | setupFormData, 9 | } from '../../services/imageGenerator.js'; 10 | import { ManifestImageResource } from '../../utils/w3c.js'; 11 | import ExceptionOf, { ExceptionType } from '../../utils/Exception.js'; 12 | 13 | interface ImageBase64ResponseBody { 14 | icons: Array; 15 | successful: boolean; 16 | } 17 | 18 | const httpTrigger: AzureFunction = async function ( 19 | context: Context, 20 | req: HttpRequest 21 | ): Promise { 22 | const body: ImageBase64ResponseBody = { 23 | icons: [], 24 | successful: false, 25 | }; 26 | 27 | try { 28 | const form = setupFormData(); 29 | 30 | // if a image url is passed use that by default 31 | if (req.query.imgUrl) { 32 | const { imgUrl } = req.query; 33 | const headTest = await fetch(imgUrl, { 34 | method: 'HEAD', 35 | }); 36 | 37 | if (!headTest.ok) { 38 | throw ExceptionOf( 39 | ExceptionType.IMAGE_GEN_IMG_NETWORK_ERROR, 40 | new Error(`Could not find requested resource at: ${imgUrl}`) 41 | ); 42 | } 43 | 44 | const img = await Jimp.read(imgUrl); 45 | 46 | if (img) { 47 | const buf = await img.getBufferAsync(Jimp.MIME_PNG); 48 | 49 | form.append('fileName', buf, { contentType: Jimp.MIME_PNG }); 50 | } else { 51 | throw ExceptionOf( 52 | ExceptionType.IMAGE_GEN_IMG_NETWORK_ERROR, 53 | new Error(`Could not find requested resource at: ${imgUrl}`) 54 | ); 55 | } 56 | } else if (req.body) { 57 | // azure functions defaults bodies to string, unless the header is specified as application/octet-stream. https://github.com/Azure/azure-functions-nodejs-worker/issues/294 58 | // how do we determine it is a valid image and not some garbage... 59 | // if file is sent, then create image generator 60 | const buf = Buffer.from(req.body, 'binary'); 61 | const img = await Jimp.read(buf); 62 | 63 | form.append('fileName', await img.getBufferAsync(Jimp.MIME_PNG), { 64 | contentType: Jimp.MIME_PNG, 65 | }); 66 | } else { 67 | throw ExceptionOf( 68 | ExceptionType.IMAGE_GEN_FILE_NOT_FOUND, 69 | new Error('the image generation code requires a file or a image url') 70 | ); 71 | } 72 | 73 | const res = await generateAllImages(context, form); 74 | context.log.info('after gen all images'); 75 | 76 | if (res) { 77 | const zip = new JSZip(); 78 | zip.loadAsync(await res.arrayBuffer()); 79 | body.icons = await getBase64Images(context, zip); 80 | } 81 | } catch (e) { 82 | context.log.error('error', e); 83 | // the file fetch path, check for HEAD and Jimp failures. 84 | } 85 | 86 | context.res = { 87 | status: 200, 88 | body, 89 | }; 90 | }; 91 | 92 | export default httpTrigger; 93 | -------------------------------------------------------------------------------- /deprecated/Report_slow/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Report_slow/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /deprecated/Report_slow/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import puppeteer from 'puppeteer'; 3 | import lighthouse, { OutputMode } from 'lighthouse'; 4 | import { screenEmulationMetrics, /*userAgents */} from 'lighthouse/core/config/constants.js'; 5 | 6 | // import { closeBrowser, getBrowser } from '../utils/browserLauncher'; 7 | import { checkParams } from '../../utils/checkParams.js'; 8 | import { analyzeServiceWorker, AnalyzeServiceWorkerResponce } from '../../utils/analyzeServiceWorker.js'; 9 | 10 | 11 | // custom use agents 12 | const userAgents = { 13 | desktop: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.42', 14 | mobile: 'Mozilla/5.0 (Linux; Android 12; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36 Edg/108.0.1462.42' 15 | } 16 | 17 | const MAX_WAIT_FOR_LOAD = 25; //seconds 18 | const MAX_WAIT_FOR_FCP = 10; //seconds 19 | 20 | const httpTrigger: AzureFunction = async function ( 21 | context: Context, 22 | req: HttpRequest 23 | ): Promise { 24 | 25 | const checkResult = checkParams(req, ['site']); 26 | if (checkResult.status !== 200){ 27 | context.res = checkResult; 28 | context.log.error(`Report: ${checkResult.body?.error.message}`); 29 | return; 30 | } 31 | 32 | context.log.info( 33 | `Report function is processing a request for site: ${req.query.site}` 34 | ); 35 | 36 | const url = req.query.site as string; 37 | const desktop = req.query.desktop == 'true'? true : undefined; 38 | 39 | const currentBrowser = await puppeteer.launch({ 40 | headless: true, 41 | defaultViewport: null, 42 | }); 43 | const page = await currentBrowser.newPage(); 44 | 45 | 46 | try { 47 | // run lighthouse audit 48 | 49 | if (page) { 50 | const webAppReport = await audit(page, url, desktop); 51 | 52 | await currentBrowser.close(); 53 | 54 | context.res = { 55 | status: 200, 56 | body: { 57 | data: webAppReport, 58 | }, 59 | }; 60 | 61 | context.log.info( 62 | `Report function is DONE processing a request for site: ${req.query.site}` 63 | ); 64 | } 65 | } catch (error: any) { 66 | await currentBrowser.close(); 67 | 68 | context.res = { 69 | status: 500, 70 | body: { 71 | error: error, 72 | }, 73 | }; 74 | 75 | if (error.name && error.name.indexOf('TimeoutError') > -1) { 76 | context.log.error( 77 | `Report function TIMED OUT processing a request for site: ${url}` 78 | ); 79 | } else { 80 | context.log.error( 81 | `Report function failed for ${url} with the following error: ${error}` 82 | ); 83 | } 84 | } 85 | }; 86 | 87 | const audit = async (browser: any, url: string, desktop?: boolean) => { 88 | 89 | // Puppeteer with Lighthouse 90 | const config = { 91 | // port: browser.port, //new URL(browser.wsEndpoint()).port, 92 | logLevel: 'info', // 'silent' | 'error' | 'info' | 'verbose' 93 | output: 'json', // 'json' | 'html' | 'csv' 94 | locale: 'en-US', 95 | 96 | maxWaitForFcp: MAX_WAIT_FOR_FCP * 1000, 97 | maxWaitForLoad: MAX_WAIT_FOR_LOAD * 1000, 98 | 99 | // disableDeviceEmulation: true, 100 | // disableStorageReset: true, 101 | // chromeFlags: [/*'--disable-mobile-emulation',*/ '--disable-storage-reset'], 102 | 103 | skipAboutBlank: true, 104 | formFactor: desktop ? 'desktop' : 'mobile', // 'mobile'|'desktop'; 105 | screenEmulation: desktop ? screenEmulationMetrics.desktop : screenEmulationMetrics.mobile, 106 | emulatedUserAgent: desktop ? userAgents.desktop : userAgents.mobile, 107 | throttlingMethod: 'provided', // 'devtools'|'simulate'|'provided'; 108 | throttling: false, 109 | onlyAudits: ['service-worker', 'installable-manifest', 'is-on-https', 'maskable-icon', 'apple-touch-icon', 'splash-screen', 'themed-omnibox', 'viewport'], 110 | // onlyCategories: ['pwa'] , 111 | // skipAudits: ['pwa-cross-browser', 'pwa-each-page-has-url', 'pwa-page-transitions', 'full-page-screenshot', 'network-requests', 'errors-in-console', 'diagnostics'], 112 | } 113 | 114 | // @ts-ignore 115 | const rawResult = await lighthouse(url, config, undefined, browser); 116 | 117 | const audits = rawResult?.lhr?.audits; 118 | const artifacts = rawResult?.artifacts; 119 | 120 | if (!audits) { 121 | return null; 122 | } 123 | 124 | let swFeatures: AnalyzeServiceWorkerResponce | null = null; 125 | // @ts-ignore 126 | if (audits['service-worker']?.details?.scriptUrl) { 127 | try{ 128 | // @ts-ignore 129 | swFeatures = audits['service-worker']?.details?.scriptUrl? await analyzeServiceWorker(audits['service-worker'].details.scriptUrl) : null; 130 | } 131 | catch(error: any){ 132 | swFeatures = { 133 | error: error 134 | } 135 | } 136 | } 137 | 138 | 139 | const report = { 140 | audits: { 141 | isOnHttps: { score: audits['is-on-https']?.score? true : false }, 142 | installableManifest: { 143 | score: audits['installable-manifest']?.score? true : false, 144 | // @ts-ignore 145 | details: { url: audits['installable-manifest']?.details?.debugData?.manifestUrl || undefined } 146 | }, 147 | serviceWorker: { 148 | score: audits['service-worker']?.score? true : false, 149 | details: { 150 | // @ts-ignore 151 | url: audits['service-worker']?.details?.scriptUrl || undefined, 152 | // @ts-ignore 153 | scope: audits['service-worker']?.details?.scopeUrl || undefined, 154 | features: swFeatures? {...swFeatures, raw: undefined} : undefined 155 | } 156 | }, 157 | appleTouchIcon: { score: audits['apple-touch-icon']?.score? true : false }, 158 | maskableIcon: { score: audits['maskable-icon']?.score? true : false }, 159 | splashScreen: { score: audits['splash-screen']?.score? true : false }, 160 | themedOmnibox: { score: audits['themed-omnibox']?.score? true : false }, 161 | viewport: { score: audits['viewport']?.score? true : false } 162 | }, 163 | artifacts: { 164 | webAppManifest: artifacts?.WebAppManifest, 165 | serviceWorker: {...artifacts?.ServiceWorker, raw: (swFeatures as { raw: string[]})?.raw || undefined }, 166 | url: artifacts?.URL, 167 | // @ts-ignore 168 | linkElements: artifacts?.LinkElements?.map(element => { delete element?.node; return element }), 169 | // @ts-ignore 170 | metaElements: artifacts?.MetaElements?.map(element => { delete element?.node; return element }) 171 | } 172 | } 173 | 174 | return report; 175 | }; 176 | 177 | export default httpTrigger; 178 | 179 | /** 180 | * @openapi 181 | * /Report: 182 | * get: 183 | * summary: Lighthouse report 184 | * description: Generate PWA-related Lighthouse report for webapp 185 | * tags: 186 | * - Report 187 | * parameters: 188 | * - $ref: ?file=components.yaml#/parameters/site 189 | * - name: desktop 190 | * schema: 191 | * type: boolean 192 | * # default: '' 193 | * in: query 194 | * description: Use desktop form factor 195 | * required: false 196 | * responses: 197 | * '200': 198 | * $ref: ?file=components.yaml#/responses/report/200 199 | */​ 200 | -------------------------------------------------------------------------------- /deprecated/ServiceWorker/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/ServiceWorker/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /deprecated/ServiceWorker/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from '@azure/functions'; 2 | import { Browser } from 'puppeteer'; 3 | import lighthouse from 'lighthouse'; 4 | 5 | import { closeBrowser, getBrowser } from '../../utils/loadPage.js'; 6 | import { checkParams } from '../../utils/checkParams'; 7 | 8 | const httpTrigger: AzureFunction = async function ( 9 | context: Context, 10 | req: HttpRequest 11 | ): Promise { 12 | 13 | const checkResult = checkParams(req, ['site']); 14 | if (checkResult.status !== 200){ 15 | context.res = checkResult; 16 | context.log.error(`ServiceWorker: ${checkResult.body?.error.message}`); 17 | return; 18 | } 19 | 20 | context.log.info( 21 | `Service Worker function is processing a request for site: ${req.query.site}` 22 | ); 23 | 24 | const url = req?.query?.site as string; 25 | 26 | const currentBrowser = await getBrowser(context); 27 | 28 | try { 29 | // run lighthouse audit 30 | 31 | if (currentBrowser) { 32 | const swInfo = await audit(currentBrowser, url); 33 | 34 | await closeBrowser(context, currentBrowser); 35 | 36 | context.res = { 37 | status: 200, 38 | body: { 39 | data: swInfo, 40 | }, 41 | }; 42 | 43 | context.log.info( 44 | `Service Worker function is DONE processing a request for site: ${req.query.site}` 45 | ); 46 | } 47 | } catch (error: any) { 48 | await closeBrowser(context, currentBrowser); 49 | 50 | context.res = { 51 | status: 500, 52 | body: { 53 | error: error, 54 | }, 55 | }; 56 | 57 | if (error.name && error.name.indexOf('TimeoutError') > -1) { 58 | context.log.error( 59 | `Service Worker function TIMED OUT processing a request for site: ${url}` 60 | ); 61 | } else { 62 | context.log.error( 63 | `Service Worker function failed for ${url} with the following error: ${error}` 64 | ); 65 | } 66 | } 67 | }; 68 | 69 | const audit = async (browser: Browser, url: string) => { 70 | // empty object that we fill with data below 71 | const swInfo: { hasSW? : boolean, scope?: boolean, offline? : boolean } = {}; 72 | 73 | // Default options to use when using 74 | // Puppeteer with Lighthouse 75 | const options = { 76 | output: 'json', 77 | logLevel: 'info', 78 | disableDeviceEmulation: true, 79 | chromeFlags: ['--disable-mobile-emulation', '--disable-storage-reset'], 80 | onlyCategories: ['pwa'], 81 | port: new URL(browser.wsEndpoint()).port, 82 | }; 83 | 84 | const runnerResult = await lighthouse(url, options); 85 | const audits = runnerResult?.lhr?.audits; 86 | 87 | if (audits) { 88 | swInfo.hasSW = audits['service-worker'].score >= 1 ? true : false; 89 | swInfo.scope = audits['service-worker'].details 90 | ? audits['service-worker'].details.scopeUrl 91 | : null; 92 | swInfo.offline = audits['installable-manifest'].score >= 1 ? true : false; 93 | 94 | return swInfo; 95 | } else { 96 | return null; 97 | } 98 | }; 99 | 100 | export default httpTrigger; 101 | -------------------------------------------------------------------------------- /deprecated/ServiceWorker/sample.dat: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure" 3 | } -------------------------------------------------------------------------------- /deprecated/Validate/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get", 10 | "post" 11 | ] 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ], 19 | "scriptFile": "../dist/Validate/index.js" 20 | } 21 | -------------------------------------------------------------------------------- /deprecated/Validate/index.ts: -------------------------------------------------------------------------------- 1 | import { AzureFunction, Context, HttpRequest } from "@azure/functions" 2 | import validate from "../schema"; 3 | 4 | 5 | const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise { 6 | context.res = { 7 | // status: 200, /* Defaults to 200 */ 8 | body: validate(req.body) 9 | }; 10 | }; 11 | 12 | export default httpTrigger; -------------------------------------------------------------------------------- /deprecated/browserLauncher.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import { LogMessages } from '../utils/logMessages.js'; 3 | 4 | import { launch, LaunchedChrome } from 'chrome-launcher'; 5 | 6 | // TODO: try to replace this with https://github.com/cezaraugusto/chromium-edge-launcher 7 | 8 | export async function getBrowser(context: Context): Promise { 9 | context.log.info(LogMessages.OPENING_BROWSER); 10 | 11 | return await launch({chromeFlags: [ 12 | '--headless', 13 | '--no-sandbox', 14 | '--enable-automation', 15 | '--disable-background-networking', 16 | '--enable-features=NetworkServiceInProcess2', 17 | '--disable-background-timer-throttling', 18 | '--disable-backgrounding-occluded-windows', 19 | '--disable-breakpad', 20 | '--disable-client-side-phishing-detection', 21 | '--disable-component-extensions-with-background-pages', 22 | '--disable-component-update', 23 | '--disable-default-apps', 24 | '--disable-dev-shm-usage', 25 | '--disable-domain-reliability', 26 | '--disable-extensions', 27 | '--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync', 28 | '--disable-hang-monitor', 29 | '--disable-ipc-flooding-protection', 30 | '--disable-popup-blocking', 31 | '--disable-prompt-on-repost', 32 | '--disable-renderer-backgrounding', 33 | '--disable-sync', 34 | '--force-color-profile=srgb', 35 | '--metrics-recording-only', 36 | '--no-first-run', 37 | '--no-default-browser-check', 38 | '--mute-audio', 39 | '--password-store=basic', 40 | '--use-mock-keychain', 41 | '--enable-blink-features=IdleDetection', 42 | '--export-tagged-pdf', 43 | '--disabe-gpu', 44 | ]}) 45 | } 46 | 47 | export async function closeBrowser( 48 | context: Context, 49 | browser?: LaunchedChrome 50 | ): Promise { 51 | if (browser) { 52 | context.log.info(LogMessages.CLOSING_BROWSER); 53 | 54 | try { 55 | await browser.kill(); 56 | } catch (err) { 57 | context.log.error('Error closing browser', err); 58 | } 59 | 60 | return; 61 | } 62 | } -------------------------------------------------------------------------------- /deprecated/icons.ts: -------------------------------------------------------------------------------- 1 | import * as stream from "stream"; 2 | import * as url from "url"; 3 | import * as Jimp from "jimp"; 4 | import { 5 | IconManifestImageResource, 6 | ScreenshotManifestImageResource, 7 | } from "../utils/interfaces.js"; 8 | 9 | export function isDataUri(uri: string): boolean { 10 | return ( 11 | uri.match(/^(data:)([\w\/\+-]*)(;charset=[\w-]+|;base64){0,1},(.*)/gi) 12 | ?.length === 1 13 | ); 14 | } 15 | 16 | type SizeString = string; 17 | export function getSize( 18 | blobName: SizeString 19 | ): { width: number; height: number } { 20 | const [widthStr, heightStr] = blobName.split("-")[0].split("x"); 21 | const width = Number(widthStr); 22 | const height = Number(heightStr); 23 | return { 24 | width, 25 | height, 26 | }; 27 | } 28 | 29 | export function isBigger(current: SizeString, other: SizeString): boolean { 30 | const { width: cW, height: cH } = getSize(current); 31 | const { width: oW, height: oH } = getSize(other); 32 | 33 | // Add aspect ratio comparisons? https://en.wikipedia.org/wiki/Aspect_ratio_(image) 34 | return cW * cH >= oW * oH; 35 | } 36 | 37 | // string, index map of entries 38 | export async function buildImageSizeMap( 39 | imageList: Array, 40 | siteUrl: string 41 | ): Promise> { 42 | const map = new Map(); 43 | 44 | for (let i = 0; i < imageList.length; i++) { 45 | const entry = imageList[i]; 46 | let sizes: Array = []; 47 | 48 | if (entry.sizes) { 49 | sizes = entry.sizes.split(" "); 50 | map.set(entry.sizes, i); // set sizes if a list of sizes. 51 | } else if ((entry as any).size) { 52 | // not in manifest spec, but a logical next step to check 53 | sizes = [(entry as any).size]; 54 | } else { 55 | // just download image and see size, Jimp uses gzipped so latency shouldn't be too horrible. I wish we could consistently use HEAD calls instead though. 56 | const img = await Jimp.read(new url.URL(entry.src, siteUrl).toString()); 57 | sizes = [`${img.getWidth()}x${img.getHeight()}`]; 58 | } 59 | 60 | sizes.forEach((size) => { 61 | map.set(size, i); 62 | }); 63 | } 64 | 65 | return map; 66 | } 67 | 68 | interface JimpStreamInterface { 69 | stream: stream.Readable; 70 | buffer: Buffer; 71 | } 72 | 73 | export async function createImageStreamFromJimp( 74 | jimpImage: Jimp 75 | ): Promise { 76 | const buffer = await jimpImage.getBufferAsync(jimpImage.getMIME()); 77 | const imageStream = new stream.Readable(); 78 | imageStream.push(buffer); 79 | imageStream.push(null); 80 | 81 | return { stream: imageStream, buffer }; 82 | } 83 | -------------------------------------------------------------------------------- /deprecated/schema.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { ValidateFunction, ErrorObject } from "ajv"; 2 | 3 | function imageResourceSchema() { 4 | return { 5 | $id: "imageResource", 6 | required: ["src"], 7 | properties: { 8 | src: { type: "string" }, 9 | sizes: { type: "string" }, 10 | type: { type: "string" }, 11 | purpose: { type: "string" } 12 | } 13 | }; 14 | } 15 | 16 | function webAppSchema() { 17 | return { 18 | $id: "webApp", 19 | type: "object", 20 | required: ["name"], 21 | properties: { 22 | dir: { type: "string" }, 23 | lang: { type: "string" }, 24 | name: { type: "string" }, 25 | short_name: { type: "string" }, 26 | description: { type: "string" }, 27 | icons: { 28 | type: "array", 29 | items: { 30 | $ref: "imageResource#" 31 | } 32 | }, 33 | screenshots: { 34 | type: "array", 35 | items: { 36 | $ref: "imageResource#" 37 | } 38 | }, 39 | categories: { 40 | type: "array", 41 | items: { 42 | type: "string" 43 | } 44 | }, 45 | iarc_rating_id: { 46 | type: "string" 47 | }, 48 | start_url: { 49 | type: "string" 50 | }, 51 | display: { 52 | type: "string" 53 | }, 54 | orientation: { 55 | type: "string" 56 | }, 57 | theme_color: { 58 | type: "string" 59 | }, 60 | background_color: { 61 | type: "string" 62 | }, 63 | scope: { 64 | type: "string" 65 | }, 66 | prefer_related_applications: { 67 | type: "boolean" 68 | } 69 | } 70 | }; 71 | } 72 | 73 | const WebManifestSchema = { 74 | $ref: "webApp#" 75 | }; 76 | 77 | export function load(schema = WebManifestSchema): ValidateFunction { 78 | const instance = new Ajv({ verbose: true }); 79 | instance.addSchema(imageResourceSchema()); 80 | instance.addSchema(webAppSchema()); 81 | 82 | return instance.compile(schema); 83 | } 84 | 85 | interface ValidateResponse { 86 | isValid: boolean; 87 | parsedSuccessfully: boolean; 88 | errors?: Array; 89 | } 90 | 91 | export default function validate(manifestObj: unknown): ValidateResponse { 92 | try { 93 | const validate = load(); 94 | if (validate(manifestObj)) { 95 | // Valid 96 | 97 | return { 98 | isValid: true, 99 | parsedSuccessfully: true 100 | }; 101 | } else { 102 | // Handle Errors 103 | // for (const error of validate.errors as Array) { 104 | // // error.keyword 105 | // } 106 | 107 | return { 108 | isValid: false, 109 | parsedSuccessfully: true, 110 | errors: validate.errors ?? [] 111 | }; 112 | } 113 | } catch (error) { 114 | // Something unexpected happened. 115 | 116 | return { 117 | isValid: false, 118 | parsedSuccessfully: false 119 | }; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /deprecated/zip.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "@azure/functions"; 2 | import { BlobItem, ContainerClient } from "@azure/storage-blob/dist"; 3 | import JSZip from "jszip"; 4 | import { 5 | IconManifestImageResource, 6 | Manifest, 7 | ScreenshotManifestImageResource, 8 | } from "../utils/interfaces.js"; 9 | import { ImageProperties } from "../utils/platform.js"; 10 | import { getTagMetadataProperties } from "../utils/storage.js"; 11 | import { ManifestImageResource } from "../utils/w3c.js"; 12 | 13 | type index = number; 14 | type ImageCategories = "icons" | "screenshots"; 15 | 16 | export async function addImageToZipAndEditManifestEntry( 17 | zip: JSZip, 18 | containerClient: ContainerClient, 19 | blob: BlobItem, 20 | manifest: Manifest, 21 | manifestIndexMap: Map, 22 | category: ImageCategories, 23 | context?: Context 24 | ) { 25 | try { 26 | const folderPath = category ? category + "/" : ""; 27 | const metadata = getTagMetadataProperties(blob.metadata ?? {}); 28 | 29 | // generated and most images have the correct size, but there's a case where a different sized image is used. 30 | let size = metadata.actualSize; 31 | if (metadata.actualSize !== metadata.sizes) { 32 | size = metadata.sizes; 33 | } 34 | 35 | const manifestEntry = getManifestEntry( 36 | size, 37 | category, 38 | manifest, 39 | manifestIndexMap 40 | ); 41 | 42 | const newPath = `${folderPath}${blob.name}`; 43 | 44 | if (manifestEntry) { 45 | manifestEntry.src = newPath; // TODO seems naive... 46 | } else { 47 | const newEntry: ManifestImageResource = { 48 | sizes: size, 49 | src: newPath, 50 | type: metadata.type, 51 | }; 52 | 53 | manifest[category].push(newEntry); 54 | } 55 | 56 | zip.file( 57 | newPath, 58 | containerClient.getBlobClient(blob.name).downloadToBuffer() 59 | ); 60 | } catch (e) { 61 | context?.log("failed to add image to zip: " + blob.name); 62 | context?.log(e); 63 | } 64 | } 65 | 66 | export function getManifestEntry( 67 | size: string, 68 | category: ImageCategories, 69 | manifest: Manifest, 70 | manifestIndexMap: Map 71 | ): IconManifestImageResource | ScreenshotManifestImageResource { 72 | const index = manifestIndexMap.get(size) as number; 73 | return manifest[category][index]; 74 | } 75 | -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "concurrency": { 12 | "dynamicConcurrencyEnabled": true, 13 | "snapshotPersistenceEnabled": false 14 | }, 15 | "functionTimeout": "00:03:00", 16 | "extensionBundle": { 17 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 18 | "version": "[4.*, 5.0.0)" 19 | } 20 | } -------------------------------------------------------------------------------- /local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_PROCESS_COUNT": "2", 5 | "FUNCTIONS_WORKER_RUNTIME": "node", 6 | "APPINSIGHTS_CONNECTION_STRING": "" 7 | } 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-v2", 3 | "version": "2.1.0", 4 | "engines": { 5 | "node": ">=18.*" 6 | }, 7 | "type": "module", 8 | "scripts": { 9 | "clean": "del-cli dist && del-cli temp", 10 | "build": "npm run clean && tsc", 11 | "build:production": "npm run prestart && npm prune --production", 12 | "watch": "tsc --w", 13 | "prestart": "npm run build && func extensions install", 14 | "start:host": "func start --node --cors *", 15 | "start": "npm-run-all --parallel start:host watch", 16 | "open-api": "node .openAPI/swagger-node-middleware.js", 17 | "open-api:generate": "npx .openAPI/cli --config .openAPI/cli/config.json", 18 | "test": "npx playwright test", 19 | "test:ui": "npx playwright test --ui" 20 | }, 21 | "description": "", 22 | "devDependencies": { 23 | "@azure/functions": "^3.5.1", 24 | "@playwright/test": "^1.40.1", 25 | "@typescript-eslint/eslint-plugin": "^6.13.1", 26 | "@typescript-eslint/parser": "^6.13.1", 27 | "applicationinsights": "^2.9.1", 28 | "del-cli": "^5.1.0", 29 | "eslint": "^8.54.0", 30 | "eslint-config-prettier": "^9.0.0", 31 | "eslint-plugin-import": "^2.29.0", 32 | "npm-run-all": "^4.1.5", 33 | "prettier": "^3.1.0", 34 | "typescript": "^5.3.2" 35 | }, 36 | "dependencies": { 37 | "@azure/storage-blob": "^12.17.0", 38 | "@puppeteer/browsers": "^2.4.0", 39 | "@pwabuilder/manifest-validation": "^0.0.9", 40 | "ajv": "^8.12.0", 41 | "azure-functions-core-tools": "^4.0.5455", 42 | "form-data": "^4.0.0", 43 | "jimp": "^0.22.10", 44 | "jsdom": "^23.0.1", 45 | "jszip": "^3.10.1", 46 | "lighthouse": "11.1.0", 47 | "node-fetch": "^3.3.2", 48 | "puppeteer": "^23.5.0", 49 | "pwabuilder-lib": "^2.1.12", 50 | "strip-json-comments": "^5.0.1", 51 | "swagger-jsdoc": "^7.0.0-rc.6", 52 | "swagger-ui-dist": "^5.10.3", 53 | "yaml": "^2.3.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /patch.js: -------------------------------------------------------------------------------- 1 | // This patch is needed to fix the issue with chrome-launcher on WSL under Azure Functions 2 | 3 | import fs from 'fs'; 4 | 5 | const fileToPatch = 'node_modules/chrome-launcher/dist/utils.js'; 6 | fs.readFile(fileToPatch, 'utf8', function (err,data) { 7 | if (err) { 8 | return console.error(err); 9 | } 10 | const result = data.replace(/case \'wsl\'\:\n\s*\/\/ We populate the user\'s Windows temp dir so the folder is correctly created later/g, 11 | `case 'wsl': 12 | if (process.env.PATCHED) 13 | return makeUnixTmpDir(); 14 | `); 15 | 16 | fs.writeFile(fileToPatch, result, 'utf8', function (err) { 17 | if (err) return console.error(err); 18 | }); 19 | }); -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests/', 14 | testMatch: '*.spec.ts', 15 | /* Maximum time one test can run for. */ 16 | timeout: 2.5 * 60 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 8000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 2, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: [ 34 | ['html', { outputFolder: 'temp/report/' }], 35 | ['json', { outputFile: 'temp/report/test_results.json' }] 36 | ], 37 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 38 | use: { 39 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 40 | actionTimeout: 0, 41 | /* Base URL to use in actions like `await page.goto('/')`. */ 42 | baseURL: 'http://localhost:7071', 43 | 44 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 45 | trace: 'on-first-retry', 46 | 47 | ignoreHTTPSErrors: true, 48 | }, 49 | 50 | /* Configure projects for major browsers */ 51 | projects: [ 52 | // { 53 | // name: 'chromium', 54 | // use: { ...devices['Desktop Chrome'] }, 55 | // }, 56 | 57 | // { 58 | // name: 'firefox', 59 | // use: { ...devices['Desktop Firefox'] }, 60 | // }, 61 | 62 | { 63 | name: 'webkit', 64 | use: { ...devices['Desktop Safari'] }, 65 | } 66 | 67 | /* Test against mobile viewports. */ 68 | // { 69 | // name: 'Mobile Chrome', 70 | // use: { ...devices['Pixel 5'] }, 71 | // }, 72 | // { 73 | // name: 'Mobile Safari', 74 | // use: { ...devices['iPhone 12'] }, 75 | // }, 76 | 77 | /* Test against branded browsers. */ 78 | // { 79 | // name: 'Microsoft Edge', 80 | // use: { channel: 'msedge' }, 81 | // }, 82 | // { 83 | // name: 'Google Chrome', 84 | // use: { channel: 'chrome' }, 85 | // }, 86 | ], 87 | 88 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 89 | outputDir: 'temp/artifacts', 90 | 91 | /* Run your local dev server before starting the tests */ 92 | webServer: { 93 | command: 'npm run start', 94 | timeout: 3 * 60 * 1000, 95 | port: 7071, 96 | reuseExistingServer: !process.env.CI 97 | } 98 | }); -------------------------------------------------------------------------------- /services/imageGenerator.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import * as Jimp from 'jimp'; 3 | import JSZip from 'jszip'; 4 | import FormData from 'form-data'; 5 | import ExceptionOf, { ExceptionType } from '../utils/Exception.js'; 6 | import { 7 | ImageGeneratorImageSpec, 8 | ImageGeneratorManifestImageResource, 9 | ImageGeneratorSources, 10 | IconManifestImageResource, 11 | ZipResult, 12 | } from '../utils/interfaces.js'; 13 | import { setIntersection } from '../utils/set.js'; 14 | import { ManifestImageResource } from '../utils/w3c.js'; 15 | 16 | const baseUrl = 'https://appimagegenerator-prod.azurewebsites.net'; 17 | const uriUrl = `${baseUrl}/api/image`; 18 | 19 | function imagesJsonUrl(source: ImageGeneratorSources) { 20 | return `https://raw.githubusercontent.com/pwa-builder/pwabuilder-Image-Generator/master/AppImageGenerator/App_Data/${source}.json`; 21 | } 22 | 23 | export async function generateAllImages( 24 | context: Context, 25 | form: FormData 26 | ): Promise { 27 | try { 28 | context.log.info('before generate all images'); 29 | 30 | const generate = await fetch(uriUrl, { 31 | method: 'POST', 32 | headers: form.getHeaders(), 33 | body: form.getBuffer(), 34 | // compress: false, 35 | }); 36 | context.log.info(generate.status, generate.statusText); 37 | 38 | const generateResponse = await generate.json() as { 39 | Uri?: string | '/api/image/'; 40 | Message?: string 41 | }; 42 | 43 | // const generateResponse: { 44 | // Uri?: string | '/api/image/'; 45 | // Message?: string; 46 | // } = await generate.json(); 47 | 48 | context.log.info('after post', generateResponse); 49 | if (generateResponse.Message) { 50 | // returned message means error 51 | 52 | throw ExceptionOf( 53 | ExceptionType.IMAGE_GEN_IMG_SERVICE_ERROR, 54 | new Error(generateResponse.Message) 55 | ); 56 | } else if (generateResponse.Uri) { 57 | return fetch(`${baseUrl}${generateResponse.Uri}`); 58 | } 59 | } catch (e) { 60 | context.log.error(e); 61 | } 62 | 63 | return undefined; 64 | } 65 | 66 | export async function getBase64Images( 67 | context: Context, 68 | zip: JSZip 69 | ): Promise> { 70 | const output: Array = []; 71 | try { 72 | const iconsFileRef = zip.file('icons.json') ?? undefined; 73 | const iconsFile = await iconsFileRef?.async('string'); 74 | 75 | if (iconsFile) { 76 | const json = JSON.parse(iconsFile) as { 77 | icons: Array>; 78 | }; 79 | 80 | const len = json.icons.length; 81 | for (let i = 0; i < len; i++) { 82 | const icon = json.icons[i]; 83 | const { src } = icon; 84 | 85 | if (src) { 86 | const file = zip.file(src) ?? undefined; 87 | const buf = await file?.async('nodebuffer'); 88 | 89 | if (!buf) { 90 | throw ExceptionOf( 91 | ExceptionType.IMAGE_GEN_FILE_NOT_FOUND, 92 | new Error(`could not get node buffer of: ${src}`) 93 | ); 94 | } 95 | 96 | const img = await Jimp.read(buf); 97 | 98 | output.push({ 99 | src: await img.getBase64Async(img.getMIME()), 100 | sizes: icon.sizes ?? `${img.getWidth()}x${img.getHeight()}`, 101 | type: img.getMIME(), 102 | purpose: 'any', 103 | }); 104 | } 105 | } 106 | } 107 | } catch (e) { 108 | context.log.error(e); 109 | } 110 | return output; 111 | } 112 | 113 | export async function generateAllImagesJimp( 114 | context: Context, 115 | image: Jimp, 116 | source: ImageGeneratorSources = ImageGeneratorSources.all 117 | ): Promise> { 118 | let output: Array = []; 119 | 120 | try { 121 | const imgJsonRes = await fetch(imagesJsonUrl(source)); 122 | const imgJson = (await imgJsonRes.json()) as Array; 123 | 124 | output = imgJson.map((info: ImageGeneratorImageSpec) => { 125 | const cur = image.clone(); 126 | const { width, height } = info; 127 | 128 | cur.resize(width, height); 129 | 130 | return { 131 | src: cur, 132 | sizes: `${width}x${height}`, 133 | type: Jimp.MIME_PNG, 134 | purpose: 'any', 135 | }; 136 | }); 137 | } catch (e) { 138 | context.log.error(e); 139 | } 140 | 141 | return output; 142 | } 143 | 144 | export async function convertToBase64( 145 | context: Context, 146 | images: Array 147 | ): Promise> { 148 | const output: Array = []; 149 | const eachSizeOnce = new Set(); // theres a lot of duplicate entries in the manifests... to save on performance. 150 | 151 | try { 152 | const len = images.length; 153 | for (let i = 0; i < len; i++) { 154 | const { src, sizes, type, purpose } = images[i]; 155 | 156 | if (!eachSizeOnce.has(sizes)) { 157 | const base64 = await src.getBase64Async(Jimp.MIME_PNG); 158 | 159 | output.push({ 160 | src: base64, 161 | sizes, 162 | type, 163 | purpose, 164 | }); 165 | eachSizeOnce.add(sizes); 166 | } 167 | } 168 | } catch (e) { 169 | context.log.error(e); 170 | } 171 | 172 | return output; 173 | } 174 | 175 | export async function generateZip( 176 | context: Context, 177 | images: Array 178 | ): Promise { 179 | const zip = new JSZip(); 180 | const eachSizeOnce = new Set(); 181 | 182 | try { 183 | const len = images.length; 184 | 185 | for (let i = 0; i < len; i++) { 186 | const { src, sizes } = images[i]; 187 | 188 | // TODO finish using the duplicates to buffer and etc. 189 | const duplicates = setIntersection( 190 | eachSizeOnce, 191 | new Set(sizes.split(' ')) 192 | ); 193 | 194 | if (!eachSizeOnce.has(sizes)) { 195 | const buffer = await src.getBufferAsync(Jimp.MIME_PNG); 196 | 197 | zip.file(`${sizes}.png`, buffer); 198 | } 199 | } 200 | } catch (e) { 201 | context.log.error(e); 202 | } 203 | return { 204 | zip, 205 | success: eachSizeOnce.size > 0, 206 | }; 207 | } 208 | 209 | export function setupFormData(): FormData { 210 | const form = new FormData(); 211 | form.append('padding', '0.0'); 212 | form.append('colorOption', 'transparent'); 213 | form.append('platform', 'windows10'); 214 | form.append('platform', 'windows'); 215 | form.append('platform', 'msteams'); 216 | form.append('platform', 'android'); 217 | form.append('platform', 'chrome'); 218 | form.append('platform', 'firefox'); 219 | 220 | return form; 221 | } 222 | -------------------------------------------------------------------------------- /tests/FindAuditServiceWorker.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import fs from 'fs'; 3 | import { UrlIsAvailable, AZURE_FUNC_TIMEOUT } from './helpers.js'; 4 | 5 | let file = fs.readFileSync('./tests/test_urls.json', 'utf8'); 6 | const array = JSON.parse(file); 7 | array.forEach(async (url: string, index: number) => { 8 | 9 | test(`${index}:api/FindServiceWorker: url, raw, json`, async ({ request, baseURL }) => { 10 | test.info().annotations.push({type: 'url', description: url }); 11 | 12 | if (await !UrlIsAvailable(request, url)) { 13 | test.info().annotations.push({type: 'reason', description: 'url unreachable' }); 14 | test.skip(); 15 | return; 16 | } 17 | 18 | let result, apiCall; 19 | try { 20 | apiCall = await request.get(`${baseURL}/api/FindServiceWorker?site=${url}`, { timeout: AZURE_FUNC_TIMEOUT }); 21 | result = await apiCall.json(); 22 | } catch (error) { 23 | if (/Request timed out after/.test(error?.message)) { 24 | test.info().annotations.push({type: 'reason', description: 'api timeout' }); 25 | test.skip(); 26 | return; 27 | } 28 | } 29 | 30 | expect(apiCall?.ok, 'status find ok').toBeTruthy(); 31 | expect(result?.content?.url, 'url').toBeTruthy(); 32 | 33 | let swURL = result?.content?.url; 34 | if (swURL) { 35 | const apiCall = await request.get(`${baseURL}/api/AuditServiceWorker?url=${swURL}`, { timeout: AZURE_FUNC_TIMEOUT }); 36 | let result: any = null; 37 | 38 | try { 39 | result = await apiCall.json(); 40 | } catch (error) {} 41 | 42 | expect(apiCall.ok, 'status audit ok').toBeTruthy(); 43 | expect(result?.content?.score, 'score').toBeTruthy(); 44 | expect(result?.content?.details?.features, 'features').toBeTruthy(); 45 | } 46 | }); 47 | }); -------------------------------------------------------------------------------- /tests/FindWebManifest.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import fs from 'fs'; 3 | import { UrlIsAvailable, AZURE_FUNC_TIMEOUT } from './helpers.js'; 4 | 5 | let file = fs.readFileSync('./tests/test_urls.json', 'utf8'); 6 | const array = JSON.parse(file); 7 | array.forEach(async (url: string, index: number) => { 8 | 9 | test(`${index}:api/FindWebManifest: url, raw, json`, async ({ request, baseURL }) => { 10 | test.info().annotations.push({type: 'url', description: url }); 11 | 12 | if (await !UrlIsAvailable(request, url)) { 13 | test.info().annotations.push({type: 'reason', description: 'url unreachable' }); 14 | test.skip(); 15 | return; 16 | } 17 | 18 | let result, apiCall; 19 | try { 20 | apiCall = await request.get(`${baseURL}/api/FindWebManifest?site=${url}`, { timeout: AZURE_FUNC_TIMEOUT }); 21 | result = await apiCall.json(); 22 | } catch (error) { 23 | if (/Request timed out after/.test(error?.message)) { 24 | test.info().annotations.push({type: 'reason', description: 'api timeout' }); 25 | test.skip(); 26 | return; 27 | } 28 | } 29 | 30 | expect(apiCall?.ok, 'status ok').toBeTruthy(); 31 | expect(result?.content?.url, 'url').toBeTruthy(); 32 | expect(result?.content?.raw, 'raw').toBeTruthy(); 33 | expect(result?.content?.json, 'json').toBeTruthy(); 34 | }); 35 | 36 | }); -------------------------------------------------------------------------------- /tests/Report.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { Report } from '../Report/type'; 3 | import fs from 'fs'; 4 | import { UrlIsAvailable, AZURE_FUNC_TIMEOUT } from './helpers.js'; 5 | 6 | let file = fs.readFileSync('./tests/test_urls.json', 'utf8'); 7 | const array = JSON.parse(file); 8 | array.forEach(async (url: string, index: number) => { 9 | 10 | await test(`${index}:api/Report: SW, manifest, isOnHttps, isInstallable`, async ({ request, baseURL }) => { 11 | test.info().annotations.push({type: 'url', description: url }); 12 | 13 | if (await !UrlIsAvailable(request, url)) { 14 | test.info().annotations.push({type: 'reason', description: 'url unreachable' }); 15 | test.skip(); 16 | return; 17 | } 18 | 19 | let result: { data: Report } | undefined, apiCall; 20 | try { 21 | apiCall = await request.get(`${baseURL}/api/Report?site=${url}&desktop=true`, { timeout: AZURE_FUNC_TIMEOUT }); 22 | result = await apiCall.json(); 23 | } catch (error) { 24 | if (/Request timed out after/.test(error?.message)) { 25 | test.info().annotations.push({type: 'reason', description: 'api timeout' }); 26 | test.skip(); 27 | return; 28 | } 29 | } 30 | 31 | expect(apiCall?.ok, 'status ok').toBeTruthy(); 32 | 33 | expect(result?.data?.audits, 'audits exists').toBeTruthy(); 34 | expect(result?.data?.audits?.isOnHttps?.score, 'isOnHttps').toBeTruthy(); 35 | expect(result?.data?.audits?.noMixedContent?.score, 'noMixedContent').toBeTruthy(); 36 | 37 | expect(result?.data?.artifacts?.webAppManifest?.url, 'manifest url').toBeTruthy(); 38 | expect(result?.data?.artifacts?.webAppManifest?.json, 'manifest json').toBeTruthy(); 39 | expect(result?.data?.audits?.installableManifest?.score, 'isInstallable').toBeTruthy(); 40 | 41 | expect(result?.data?.artifacts?.serviceWorker?.url, 'SW url').toBeTruthy(); 42 | expect(result?.data?.audits?.serviceWorker?.details.features, 'SW features').toBeTruthy(); 43 | 44 | expect(result?.data?.audits?.offlineSupport?.score, 'offlineSupport').toBeTruthy(); 45 | }); 46 | }); -------------------------------------------------------------------------------- /tests/drafts/FetchWebManifest.draft.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import fs from 'fs'; 3 | 4 | const AZURE_FUNC_TIMEOUT = 3 * 60 * 1000; 5 | 6 | let file = fs.readFileSync('./tests/test_urls.json', 'utf8'); 7 | const array = JSON.parse(file); 8 | 9 | array.forEach(async (url: string, index: number) => { 10 | 11 | test(`${index}:api/FetchWebManifest: url, raw, json`, async ({ request, baseURL }) => { 12 | try { 13 | const checkURL = await request.get(`${url}`, { timeout: 15000 }); 14 | if (!checkURL.ok && checkURL.status() != 302) { 15 | test.skip(); 16 | return; 17 | } 18 | } catch (error) { 19 | test.skip(); 20 | return; 21 | } 22 | 23 | const apiCall = await request.get(`${baseURL}/api/FetchWebManifest?site=${url}`, { timeout: AZURE_FUNC_TIMEOUT }); 24 | let result = await apiCall.json(); 25 | 26 | expect(apiCall.ok, 'status ok').toBeTruthy(); 27 | expect(result?.content?.url, 'url').toBeTruthy(); 28 | expect(result?.content?.json, 'json').toBeTruthy(); 29 | }); 30 | 31 | }); -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { APIRequestContext } from "@playwright/test"; 2 | 3 | export async function UrlIsAvailable(request: APIRequestContext, url: string): Promise { 4 | try { 5 | const checkURL = await request.get(`${url}`, { timeout: 15000 }); 6 | if (!checkURL.ok /* && checkURL.status() != 302*/) { 7 | return false; 8 | } 9 | } catch (error) { 10 | return false; 11 | } 12 | 13 | return true; 14 | } 15 | 16 | export const AZURE_FUNC_TIMEOUT = 2 * 60 * 1000; -------------------------------------------------------------------------------- /tests/test_urls.json: -------------------------------------------------------------------------------- 1 | ["https://webboard.app", 2 | "https://maker4.com.br/meu-jardim-login/", 3 | "https://app-demo.hemomedika.com.ua/", 4 | "https://support.hixongroup.com/", 5 | "https://metrowest.github.io/GPS/", 6 | "https://www.khmyznikov.com/pwa-install/", 7 | "https://microsoftedge.github.io/Demos/wami/", 8 | "https://breedershub.in", 9 | "https://ril.kryptografische.biz/", 10 | "https://cryptojam.net", 11 | "https://microsoftedge.github.io/Demos/pwamp/", 12 | "https://antidrop.fr/", 13 | "https://paintz.app/", 14 | "https://discoverkava.com/install/", 15 | "https://app.abaetefest.com.br/"] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "target": "es2022", 5 | "lib": ["ES2022", "DOM"], 6 | "outDir": "dist", 7 | "rootDir": ".", 8 | "sourceMap": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "nodenext", 12 | "forceConsistentCasingInFileNames": true, 13 | "noImplicitAny": false, 14 | "skipLibCheck": true 15 | }, 16 | "ts-node": { 17 | "esm": true, 18 | "experimentalSpecifierResolution": "node", 19 | }, 20 | "include": ["**/*.ts", "**/*.mts"], 21 | "exclude": ["node_modules", "deprecated", "dist", "tests"] 22 | } -------------------------------------------------------------------------------- /utils/Exception.ts: -------------------------------------------------------------------------------- 1 | export enum ExceptionType { 2 | MANIFEST_NOT_FOUND = 'MANIFEST_NOT_FOUND', 3 | MANIFEST_FILE_UNSUPPORTED = 'MANIFEST_FILE_UNSUPPORTED', 4 | BLOB_STORAGE_FAILURE = 'BLOB_STORAGE_FAILURE', 5 | BLOB_STORAGE_FAILURE_IMAGE = 'BLOB_STORAGE_FAILURE_IMAGE', 6 | BLOB_READ_FAILURE = 'BLOB_READ_FAILURE', 7 | BROWSER_CLOSE_FAILURE = 'BROWSER_CLOSE_FAILURE', 8 | IMAGE_GEN_IMG_NETWORK_ERROR = 'IMAGE_GEN_IMG_NETWORK_ERROR', 9 | IMAGE_GEN_IMG_SERVICE_ERROR = 'IMAGE_GEN_IMG_SERVICE_ERROR', 10 | IMAGE_GEN_FILE_NOT_FOUND = 'IMAGE_GEN_FILE_NOT_FOUND', 11 | } 12 | 13 | export enum ExceptionMessage { 14 | MANIFEST_NOT_FOUND = 'failed to find the manifest', 15 | MANIFEST_FILE_UNSUPPORTED = 'failed to read the json of the submitted manifest file', 16 | BLOB_STORAGE_FAILURE = 'failed to create the azure resources for generating the app', 17 | BLOB_STORAGE_FAILURE_IMAGE = 'failed to upload image to blob storage', 18 | BLOB_READ_FAILURE = 'failed to fetch resource from blob storage', 19 | BROWSER_CLOSE_FAILURE = 'Failed to close browser', 20 | IMAGE_GEN_IMG_NETWORK_ERROR = 'failed to connect or receive a successful response from the image generator service', 21 | IMAGE_GEN_IMG_SERVICE_ERROR = 'the generator service returns an error', 22 | IMAGE_GEN_FILE_NOT_FOUND = 'failed to retrieve the image resource from the site, or the file blob', 23 | } 24 | 25 | /* 26 | Top Level exception wrapper for better error handling based on ExceptionTypes. 27 | Message and stack are the parents, just provides syntactic sugar on the name field for easier comparison. 28 | */ 29 | export class ExceptionWrap { 30 | type: ExceptionType; 31 | error: Error; 32 | 33 | constructor(type: ExceptionType, error: Error) { 34 | this.type = type; 35 | this.error = error; 36 | } 37 | 38 | get name(): string { 39 | return this.error.name; 40 | } 41 | 42 | get message(): string { 43 | return this.error.message; 44 | } 45 | 46 | get stack(): string | undefined { 47 | return this.error?.stack; 48 | } 49 | 50 | // Use to differentiate Exception wrap types easily, or use switch (exception.type) {}. 51 | isOf(type: ExceptionType): boolean { 52 | return this.type === type; 53 | } 54 | } 55 | 56 | export default function ExceptionOf( 57 | type: ExceptionType, 58 | error: Error 59 | ): ExceptionWrap { 60 | return new ExceptionWrap(ExceptionType[type], error); 61 | } 62 | -------------------------------------------------------------------------------- /utils/analytics.ts: -------------------------------------------------------------------------------- 1 | import { validateSingleField } from '@pwabuilder/manifest-validation'; 2 | import * as appInsights from 'applicationinsights'; 3 | 4 | let telemetryClient; 5 | 6 | enum AppInsightsStatus { 7 | ENABLED = 1, 8 | DISABLED = 0, 9 | DEFAULT = -1, 10 | } 11 | 12 | var appInsightsStatus: AppInsightsStatus = AppInsightsStatus.DEFAULT; 13 | 14 | function initAnalytics() { 15 | try { 16 | if (!process.env.APPINSIGHTS_CONNECTION_STRING) 17 | throw('env.APPINSIGHTS_CONNECTION_STRING is EMPTY'); 18 | 19 | console.log('proces.', process.env.APPINSIGHTS_CONNECTION_STRING); 20 | telemetryClient = new appInsights.TelemetryClient( 21 | process.env.APPINSIGHTS_CONNECTION_STRING 22 | ); 23 | appInsightsStatus = AppInsightsStatus.ENABLED; 24 | } catch (e) { 25 | console.warn('App Insights not enabled', e); 26 | appInsightsStatus = AppInsightsStatus.DISABLED; 27 | } 28 | } 29 | 30 | export function trackEvent( 31 | analyticsInfo: AnalyticsInfo, 32 | error: string | null, 33 | success: boolean 34 | ) { 35 | initAnalytics(); 36 | if ( 37 | telemetryClient == null || 38 | telemetryClient == undefined || 39 | appInsightsStatus == AppInsightsStatus.DISABLED 40 | ) { 41 | return; 42 | } 43 | 44 | var properties: any = { 45 | url: analyticsInfo.url, 46 | platformId: analyticsInfo.platformId, 47 | platformIdVersion: analyticsInfo.platformIdVersion, 48 | }; 49 | 50 | try { 51 | if ( 52 | analyticsInfo.correlationId != null && 53 | analyticsInfo.correlationId != undefined && 54 | typeof analyticsInfo.correlationId == 'string' 55 | ) { 56 | telemetryClient.context.tags[telemetryClient.context.keys.operationId] = 57 | analyticsInfo.correlationId; 58 | } 59 | if (success) { 60 | telemetryClient.trackEvent({ 61 | name: 'ReportCardEvent', 62 | properties: { properties, ...analyticsInfo.properties }, 63 | }); 64 | } else { 65 | properties.error = error; 66 | telemetryClient.trackEvent({ 67 | name: 'ReportCardFailureEvent', 68 | properties: properties, 69 | }); 70 | } 71 | telemetryClient.flush(); 72 | } catch (e) { 73 | console.error(e); 74 | } 75 | } 76 | 77 | export async function uploadToAppInsights( 78 | webAppReport: any, 79 | analyticsInfo: AnalyticsInfo 80 | ) { 81 | try { 82 | if (webAppReport.artifacts.webAppManifest?.json) { 83 | const _manifest = webAppReport.artifacts.webAppManifest?.json; 84 | console.log(_manifest); 85 | analyticsInfo.properties.hasManifest = true; 86 | analyticsInfo.properties.name = 87 | (_manifest['name'] != undefined && 88 | (await validateSingleField('name', _manifest['name'])).valid) || 89 | false; 90 | analyticsInfo.properties.hasBackgroundColor = 91 | (_manifest['background-color'] != undefined && 92 | ( 93 | await validateSingleField( 94 | 'background-color', 95 | _manifest['background-color'] 96 | ) 97 | ).valid) || 98 | false; 99 | analyticsInfo.properties.hasCategories = 100 | (_manifest['categories'] != undefined && 101 | (await validateSingleField('categories', _manifest['categories'])) 102 | .valid) || 103 | false; 104 | analyticsInfo.properties.hasDescription = 105 | (_manifest['description'] != undefined && 106 | (await validateSingleField('description', _manifest['description'])) 107 | .valid) || 108 | false; 109 | analyticsInfo.properties.hasFileHandlers = 110 | (_manifest['file_handlers'] != undefined && 111 | ( 112 | await validateSingleField( 113 | 'file_handlers', 114 | _manifest['file_handlers'] 115 | ) 116 | ).valid) || 117 | false; 118 | analyticsInfo.properties.hasLaunchHandlers = 119 | (_manifest['launch_handler'] != undefined && 120 | ( 121 | await validateSingleField( 122 | 'launch_handler', 123 | _manifest['launch_handler'] 124 | ) 125 | ).valid) || 126 | false; 127 | analyticsInfo.properties.hasPreferRelatedApps = 128 | (_manifest['prefer_related_applications'] != undefined && 129 | ( 130 | await validateSingleField( 131 | 'prefer_related_applications', 132 | _manifest['prefer_related_applications'] 133 | ) 134 | ).valid) || 135 | false; 136 | analyticsInfo.properties.hasProtocolHandlers = 137 | (_manifest['protocol_handlers'] != undefined && 138 | ( 139 | await validateSingleField( 140 | 'protocol_handlers', 141 | _manifest['protocol_handlers'] 142 | ) 143 | ).valid) || 144 | false; 145 | analyticsInfo.properties.hasRelatedApps = 146 | (_manifest['related_applications'] != undefined && 147 | ( 148 | await validateSingleField( 149 | 'related_applications', 150 | _manifest['related_applications'] 151 | ) 152 | ).valid) || 153 | false; 154 | analyticsInfo.properties.hasScreenshots = 155 | (_manifest['screenshots'] != undefined && 156 | (await validateSingleField('screenshots', _manifest['screenshots'])) 157 | .valid) || 158 | false; 159 | analyticsInfo.properties.hasShareTarget = 160 | (_manifest['share_target'] != undefined && 161 | (await validateSingleField('share_target', _manifest['share_target'])) 162 | .valid) || 163 | false; 164 | analyticsInfo.properties.hasShortcuts = 165 | (_manifest['shortcuts'] != undefined && 166 | (await validateSingleField('shortcuts', _manifest['shortcuts'])) 167 | .valid) || 168 | false; 169 | analyticsInfo.properties.hasThemeColor = 170 | (_manifest['theme_color'] != undefined && 171 | (await validateSingleField('theme_color', _manifest['theme_color'])) 172 | .valid) || 173 | false; 174 | analyticsInfo.properties.hasRating = 175 | (_manifest['iarc_rating_id'] != undefined && 176 | ( 177 | await validateSingleField( 178 | 'iarc_rating_id', 179 | _manifest['iarc_rating_id'] 180 | ) 181 | ).valid) || 182 | false; 183 | analyticsInfo.properties.hasWidgets = 184 | (_manifest['widgets'] != undefined && 185 | (await validateSingleField('widgets', _manifest['widgets'])).valid) || 186 | false; 187 | analyticsInfo.properties.hasIcons = 188 | (_manifest['icons'] != undefined && 189 | (await validateSingleField('icons', _manifest['icons'])).valid) || 190 | false; 191 | analyticsInfo.properties.hasEdgeSidePanel = 192 | (_manifest['edge_side_panel'] != undefined && 193 | (await validateSingleField('edge_side_panel', _manifest['edge_side_panel'])).valid) || 194 | false; 195 | analyticsInfo.properties.hasDisplayOverride = 196 | (_manifest['display_override'] != undefined && 197 | (await validateSingleField('display_override', _manifest['display_override'])).valid) || 198 | false; 199 | analyticsInfo.properties.hasHandleLinks = 200 | (_manifest['handle_links'] != undefined && 201 | (await validateSingleField('handle_links', _manifest['handle_links'])).valid) || 202 | false; 203 | } 204 | else { 205 | analyticsInfo.properties.hasManifest = false; 206 | } 207 | if (webAppReport.audits.serviceWorker) { 208 | analyticsInfo.properties.hasServiceWorker = 209 | webAppReport.audits.serviceWorker.score; 210 | 211 | if (webAppReport.audits.serviceWorker.details.features) { 212 | const _features = webAppReport.audits.serviceWorker.details.features; 213 | analyticsInfo.properties.hasBackgroundSync = 214 | _features.detectedBackgroundSync; 215 | analyticsInfo.properties.hasPeriodicBackgroundSync = 216 | _features.detectedPeriodicBackgroundSync; 217 | analyticsInfo.properties.hasSignsOfLogic = 218 | _features.detectedSignsOfLogic; 219 | analyticsInfo.properties.hasEmptyLogic = 220 | _features.detectedEmpty; 221 | analyticsInfo.properties.hasPushRegistration = 222 | _features.detectedPushRegistration; 223 | } 224 | } 225 | 226 | if (webAppReport.audits.offlineSupport) { 227 | analyticsInfo.properties.hasOfflineSupport = 228 | webAppReport.audits.offlineSupport.score; 229 | } 230 | } catch (e) { 231 | console.warn('Could not log entry', e); 232 | return; 233 | } 234 | trackEvent(analyticsInfo, null, true); 235 | } 236 | 237 | export class AnalyticsInfo { 238 | url: string | null = null; 239 | platformId: string | null = null; 240 | platformIdVersion: string | null = null; 241 | correlationId: string | null = null; 242 | properties: any; 243 | } 244 | -------------------------------------------------------------------------------- /utils/analyzeServiceWorker.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { userAgents } from 'lighthouse/core/config/constants.js'; 3 | 4 | const USER_AGENT = `${userAgents.desktop} PWABuilderHttpAgent`; 5 | const FETCH_INIT = { headers: { 'User-Agent': USER_AGENT } }; 6 | 7 | const pushRegexes = [ 8 | new RegExp(/[.|\n\s*]addEventListener\s*\(\s*['"]push['"]/m), // .addEventListener('push') or .addEventListener("push") or [new line] addEventListener('push') 9 | new RegExp(/[.|\n\s*]onpush\s*=/m) // self.onpush = ... [new line] onpush = ... 10 | ]; 11 | const periodicSyncRegexes = [ 12 | new RegExp(/[.|\n\s*]addEventListener\s*\(\s*['"]periodicsync['"]/m), // .addEventListener("periodicsync") and .addEventListener('periodicsync') [new line] addEventListener('periodicsync') 13 | new RegExp(/[.|\n\s*]onperiodicsync\s*=/m) // self.onperiodicsync = ... [new line] onperiodicsync = ... 14 | ]; 15 | const backgroundSyncRegexes = [ 16 | new RegExp(/[.|\n\s*]addEventListener\s*\(\s*['"]sync['"]/m), // .addEventListener("sync") and .addEventListener('sync') [new line] addEventListener('sync') 17 | new RegExp(/[.|\n\s*]onsync\s*=/m), // self.onsync = function(...) [new line] onsync = function(...) 18 | new RegExp('BackgroundSyncPlugin') // new workbox.backgroundSync.BackgroundSyncPlugin(...) 19 | ]; 20 | const serviceWorkerRegexes = [ 21 | new RegExp(/importScripts|self\.|^self/m), 22 | new RegExp(/[.|\n\s*]addAll/m), 23 | new RegExp(/[.|\n\s*]addEventListener\s*\(\s*['"]install['"]/m), 24 | new RegExp(/[.|\n\s*]addEventListener\s*\(\s*['"]fetch['"]/m), 25 | ]; 26 | const emptyRegexes = [ 27 | new RegExp(/\.addEventListener\(['"]fetch['"],\(?(function)?\(?\w*\)?(=>)?{?(return(?!\w)|\w+\.respondWith\(fetch\(\w+\.request\)(?!\.catch)|})/mg) 28 | ] 29 | 30 | /* 31 | empty examples: 32 | 33 | self.addEventListener("fetch",(function(e){})) 34 | self.addEventListener("fetch",(function(){})) 35 | self.addEventListener("fetch",(function(event){e.respondWith(fetch(event.request))})) 36 | self.addEventListener('fetch',(()=>{})) 37 | self.addEventListener("fetch",(event=>{event.respondWith(fetch(event.request))})); 38 | self.addEventListener('fetch',function(event){}); 39 | self.addEventListener('fetch',function(){return;}); 40 | self.addEventListener("fetch",function(event){event.respondWith(fetch(event.request));}); 41 | self.addEventListener('fetch',()=>{return;}); 42 | self.addEventListener("fetch",(e)=>{event.respondWith(fetch(event.request));}); 43 | */ 44 | 45 | async function findAndFetchImportScripts(code: string, origin?: string): Promise { 46 | // Use a regular expression to find all importScripts statements in the code 47 | const importScripts = code.match(/importScripts\s*\((.+?)\)/g); 48 | 49 | // If there are no import statements, return an empty array 50 | if (!importScripts) { 51 | return []; 52 | } 53 | 54 | // For each import statement, extract the URL of the imported script 55 | let urls = importScripts.flatMap(statement => { 56 | const matches = statement.match(/\(\s*(["'](.+)["'])\s*\)/); 57 | if (matches && matches.length > 2) { 58 | return matches[2]; 59 | } 60 | return []; 61 | }) as string[]; 62 | 63 | // Parse the URLs and remove any invalid ones 64 | if (urls?.length){ 65 | urls = urls.flatMap((url) => { 66 | if (/(https:)/.test(url)) { 67 | try { return new URL(url).href } catch (error) { } 68 | } 69 | else if (origin) { 70 | try { return new URL(url, origin).href } catch (error) { } 71 | } 72 | 73 | return []; 74 | }) 75 | 76 | } 77 | 78 | // Fetch the content of each script 79 | const fetchPromises = urls.map(url => fetch(url, FETCH_INIT)); 80 | const responses = await Promise.all(fetchPromises); 81 | 82 | // Return the content of the scripts as an array of strings 83 | const responcePromises = responses.map(response => response.text()); 84 | const contents = await Promise.all(responcePromises); 85 | 86 | return contents; 87 | } 88 | 89 | export type AnalyzeServiceWorkerResponse = { 90 | detectedBackgroundSync?: boolean, 91 | detectedPeriodicBackgroundSync?: boolean, 92 | detectedPushRegistration?: boolean, 93 | detectedSignsOfLogic?: boolean, 94 | detectedEmpty?: boolean, 95 | raw?: string[], 96 | error?: string 97 | } 98 | 99 | export async function analyzeServiceWorker(serviceWorkerUrl?: string, serviceWorkerContent?: string): Promise { 100 | let content = serviceWorkerContent; 101 | const separateContent: string[] = []; 102 | if (serviceWorkerUrl) { 103 | const response = await fetch(serviceWorkerUrl, FETCH_INIT); 104 | content = response.ok ? await response.text() : undefined; 105 | } 106 | if (content?.length && typeof content == 'string') { 107 | separateContent.push(content); 108 | 109 | try { 110 | // expand main SW content with imported scripts 111 | const scriptsContent = await findAndFetchImportScripts(content, serviceWorkerUrl? new URL(serviceWorkerUrl).origin: undefined); 112 | scriptsContent.forEach(scriptContent => { 113 | (content as string) += scriptContent; 114 | separateContent.push(scriptContent as string); 115 | }); 116 | } catch (error) { 117 | } 118 | 119 | const _swSize = Buffer.from(content).length / 1024; 120 | content = content.replace(/\n+|\s+|\r/gm, ''); 121 | 122 | return { 123 | detectedBackgroundSync: backgroundSyncRegexes.some((reg) => reg.test(content as string)), 124 | detectedPeriodicBackgroundSync: periodicSyncRegexes.some((reg) => reg.test(content as string)), 125 | detectedPushRegistration: pushRegexes.some((reg) => reg.test(content as string)), 126 | detectedSignsOfLogic: serviceWorkerRegexes.some((reg) => reg.test(content as string)), 127 | detectedEmpty: emptyRegexes.some((reg) => reg.test(content as string)) || _swSize < 0.2, 128 | 129 | raw: _swSize < 2048 ? separateContent: ['>2Mb'] 130 | } 131 | } 132 | return { 133 | error: `analyzeServiceWorker: no content of Service Worker or it's unreachable` 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /utils/base64.ts: -------------------------------------------------------------------------------- 1 | type Base64String = string; 2 | 3 | export function btoa(str: string): Base64String { 4 | return Buffer.from(str, "base64").toString(); 5 | } 6 | 7 | export function atob(str: string | Buffer): Base64String { 8 | if (str instanceof Buffer) { 9 | return str.toString("base64"); 10 | } 11 | 12 | return Buffer.from(str as string, "base64").toString("base64"); 13 | } 14 | -------------------------------------------------------------------------------- /utils/checkParams.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from "@azure/functions"; 2 | 3 | interface IOutputStatus { status: 200 | 400 | 500, body? : { error: { object: Error, message: string | string[] }} }; 4 | 5 | export function checkParams(req: HttpRequest, params: Array): IOutputStatus { 6 | const output: IOutputStatus = { 7 | status: 200 8 | }; 9 | 10 | params.some((param: string) => { 11 | if (!req?.query || !req.query[param]) { 12 | output.status = 400; 13 | const _err = new Error(`Exception - no '${param}' param`); 14 | output.body = { 15 | error: { 16 | object: _err, 17 | message: _err.message 18 | } 19 | } 20 | return true; 21 | } 22 | }); 23 | 24 | return output; 25 | } 26 | 27 | export function checkBody(req: HttpRequest, params: Array): IOutputStatus { 28 | const output: IOutputStatus = { 29 | status: 200 30 | }; 31 | 32 | params.some((param: string) => { 33 | if (!req?.body || !req.body[param]) { 34 | output.status = 400; 35 | const _err = new Error(`Exception - no '${param}' param`); 36 | output.body = { 37 | error: { 38 | object: _err, 39 | message: _err.message 40 | } 41 | } 42 | return true; 43 | } 44 | }); 45 | 46 | return output; 47 | } -------------------------------------------------------------------------------- /utils/fetch-headers.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from '@azure/functions'; 2 | 3 | export enum MimeTypes { 4 | formData = 'multipart/form-data', 5 | png = 'image/png', 6 | jpeg = 'image/jpeg', 7 | } 8 | 9 | export function getContentType(req: HttpRequest): string { 10 | return req.headers['Content-Type'] || req.headers['content-type'] || ''; 11 | } 12 | 13 | export function headerHasMimeType(req: HttpRequest, type: MimeTypes): boolean { 14 | return (getContentType(req)).includes(type); 15 | } 16 | 17 | export function isFormData(req: HttpRequest): boolean { 18 | return headerHasMimeType(req, MimeTypes.formData); 19 | } 20 | -------------------------------------------------------------------------------- /utils/getManifest.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import fetch from 'node-fetch'; 3 | import ExceptionOf, { ExceptionType as Type } from './Exception.js'; 4 | import { Manifest } from './interfaces.js'; 5 | import loadPage, { closeBrowser } from './loadPage.js'; 6 | 7 | export interface ManifestInformation { 8 | json: Manifest; 9 | url: string; 10 | } 11 | 12 | export async function getManifest(site: string, context: Context) { 13 | const siteData = await loadPage(site, context); 14 | 15 | if (siteData instanceof Error || !siteData) { 16 | context.log.info('did not get manifest'); 17 | return undefined; 18 | } 19 | 20 | // siteData.sitePage.setRequestInterception(true); 21 | 22 | // const whiteList = ['document', 'plain', 'script', 'javascript']; 23 | // siteData.sitePage.on('request', req => { 24 | // const type = req.resourceType(); 25 | // if (whiteList.some(el => type.indexOf(el) >= 0)) { 26 | // req.continue(); 27 | // } else { 28 | // req.abort(); 29 | // } 30 | // }); 31 | 32 | let manifestUrl: string | undefined; 33 | try{ 34 | manifestUrl = await siteData?.sitePage.$eval( 35 | 'link[rel=manifest]', 36 | (el: Element) => { 37 | const anchorEl = el as HTMLAnchorElement; 38 | return anchorEl.href; 39 | } 40 | ); 41 | } catch (e) { 42 | context.log.error('getManifest: failed to find element link[rel=manifest]'); 43 | } 44 | 45 | await closeBrowser(context, siteData.browser); 46 | 47 | if (manifestUrl) { 48 | const response = await fetch(manifestUrl); 49 | const jsonResponse = JSON.parse((await response.text()).trim()); 50 | // await closeBrowser(context, siteData.browser); 51 | return { 52 | json: jsonResponse, 53 | url: manifestUrl, 54 | }; 55 | } 56 | // await closeBrowser(context, siteData.browser); 57 | return undefined; 58 | } 59 | 60 | export async function getManifestWithHTMLParse(site: string, context: Context) { 61 | const manifestTestUrl = `https://pwabuilder-manifest-finder.azurewebsites.net/api/findmanifest?url=${encodeURIComponent( 62 | site 63 | )}`; 64 | 65 | const response = await fetch(manifestTestUrl); 66 | if (!response.ok) { 67 | context.log('Fetching manifest via HTML parsing service failed', response); 68 | return null; 69 | } 70 | const responseData = JSON.parse((await response.text()).trim()); 71 | if (responseData.error || !responseData.manifestContents) { 72 | return undefined; 73 | } 74 | return { 75 | json: responseData.manifestContents, 76 | url: responseData.manifestUrl, 77 | }; 78 | } 79 | export async function getManifestTwoWays( 80 | site: string, 81 | context: Context 82 | ): Promise { 83 | try { 84 | const htmlParse = await getManifestWithHTMLParse(site, context); 85 | if (htmlParse !== null && htmlParse !== undefined) { 86 | context.log('HTML PARSE OUTPUT', htmlParse); 87 | return htmlParse; 88 | } else { 89 | const puppeteerResponse = await getManifest(site, context); //This uses puppeteer 90 | context.log('PUPPETEER OUTPUT', puppeteerResponse); 91 | if (puppeteerResponse !== null && puppeteerResponse !== undefined) { 92 | return puppeteerResponse; 93 | } else 94 | throw ExceptionOf( 95 | Type.MANIFEST_NOT_FOUND, 96 | new Error('Could not find a manifest') 97 | ); 98 | } 99 | } catch (e: any) { 100 | throw ExceptionOf(Type.MANIFEST_NOT_FOUND, e); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /utils/getManifestByLink.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | import fetch from 'node-fetch'; 3 | import stripJsonComments from 'strip-json-comments'; 4 | import { userAgents } from 'lighthouse/core/config/constants.js'; 5 | 6 | const USER_AGENT = `${userAgents.desktop} PWABuilderHttpAgent`; 7 | 8 | export async function getManifestByLink(link: string, site: string): Promise<{link?: string, json?: unknown, raw?: string, error?: unknown}> { 9 | let error: unknown = 'no manifest or site link provided'; 10 | 11 | if (link && site) { 12 | let json: unknown | null = null; 13 | let raw: string = ''; 14 | 15 | if (!link.startsWith('http') && !link.startsWith('data:')) { 16 | link = new URL(link, site).href; 17 | } 18 | 19 | // if (/\.(json|webmanifest)/.test(link) || link.startsWith('data:')){ 20 | try { 21 | const response = await Promise.allSettled([ 22 | fetch(link, { redirect: 'follow', follow: 2, headers: { 'User-Agent': USER_AGENT } }), 23 | fetch(link, { redirect: 'follow', follow: 2, headers: { 'User-Agent': `${USER_AGENT} curl/8.0.1` } })] 24 | ); 25 | 26 | let raws = [ 27 | response[0].status == 'fulfilled' ? await response[0].value.text() : null, 28 | response[1].status == 'fulfilled' ? await response[1].value.text() : null 29 | ]; 30 | let jsons: unknown[] = [null, null]; 31 | 32 | try{ 33 | jsons[0] = (JSON.parse(clean(raws[0] || ''))); 34 | } catch(e) {} 35 | try{ 36 | jsons[1] = (JSON.parse(clean(raws[1] || ''))); 37 | } catch(e) {} 38 | 39 | if (jsons[0]) { 40 | json = jsons[0]; 41 | raw = raws[0] || ''; 42 | } 43 | else if (jsons[1]) { 44 | json = jsons[1]; 45 | raw = raws[1] || ''; 46 | } 47 | else { 48 | throw 'Error while JSON parsing'; 49 | } 50 | 51 | return { 52 | link, 53 | json, 54 | raw 55 | } 56 | } catch (err) { 57 | return await puppeteerAttempt(link); 58 | } 59 | } 60 | return { 61 | error 62 | } 63 | } 64 | 65 | async function puppeteerAttempt(link: string): Promise<{link?: string, json?: unknown, raw?: string, error?: unknown}> { 66 | let error: unknown = 'no manifest link provided'; 67 | 68 | if (link) { 69 | let json: unknown | null = null; 70 | let raw = ''; 71 | 72 | try { 73 | const browser = await puppeteer.launch({headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']}); 74 | const page = await browser.newPage(); 75 | await page.setUserAgent(USER_AGENT); 76 | 77 | try{ 78 | await page.goto(link, {timeout: 5000, waitUntil: 'domcontentloaded'}); 79 | 80 | raw = await page.evaluate(() => document.querySelector('body')?.innerText) 81 | || await page.evaluate(() => document.documentElement.outerHTML); 82 | } catch (err) { 83 | throw err; 84 | } finally { 85 | await browser.close(); 86 | } 87 | 88 | try { 89 | json = JSON.parse(clean(raw)); 90 | } catch (err) { 91 | throw err; 92 | } 93 | 94 | return { 95 | link, 96 | json, 97 | raw 98 | } 99 | } 100 | catch (err) { 101 | error = err; 102 | } 103 | } 104 | return { 105 | error 106 | } 107 | } 108 | 109 | function clean(raw: string) { 110 | // raw = raw.replace(/\r|\n/g, '').replace(/\/\*.+\*\//g, '') 111 | return stripJsonComments(raw); 112 | } -------------------------------------------------------------------------------- /utils/getManifestFromFile.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from '@azure/functions'; 2 | import ExceptionOf, { ExceptionType as Type } from './Exception.js'; 3 | import { Manifest } from './interfaces.js'; 4 | 5 | export enum ValidContentType { 6 | webmanifest = 'application/manifest+json', 7 | json = 'application/json', 8 | binary = 'application/octet-stream', 9 | } 10 | 11 | export default async function getManifestFromFile( 12 | req: HttpRequest 13 | ): Promise { 14 | try { 15 | switch (req.headers['content-type']) { 16 | case ValidContentType.json: 17 | case ValidContentType.webmanifest: 18 | return handleJson(req); 19 | case ValidContentType.binary: 20 | return handleBinary(req); 21 | default: 22 | throw TypeError('unsupported file type'); 23 | } 24 | } catch (e: any) { 25 | throw ExceptionOf(Type.MANIFEST_FILE_UNSUPPORTED, e); 26 | } 27 | } 28 | 29 | export function ifSupportedFile(req: HttpRequest): boolean { 30 | switch (req.headers['content-type']) { 31 | case ValidContentType.webmanifest: 32 | case ValidContentType.json: 33 | case ValidContentType.binary: 34 | return true; 35 | 36 | default: 37 | return false; 38 | } 39 | } 40 | 41 | // conversion is automatic! 42 | function handleJson(req: HttpRequest) { 43 | return req.body; 44 | } 45 | 46 | // Azure wraps in a files in a buffer by default! 47 | async function handleBinary(req: HttpRequest) { 48 | const jsonFileString = req.body.toString('utf-8'); 49 | return JSON.parse(jsonFileString); 50 | } 51 | -------------------------------------------------------------------------------- /utils/interfaces.ts: -------------------------------------------------------------------------------- 1 | type HexCode = string; 2 | import { Browser, Page, HTTPResponse } from 'puppeteer'; 3 | import * as Jimp from 'jimp'; 4 | import JSZip from 'jszip'; 5 | import { 6 | ManifestImageResource, 7 | WidthByHeight, 8 | W3CPurpose, 9 | SpaceSeparatedList, 10 | ExternalApplicationResource, 11 | ShortcutItem, 12 | } from './w3c.js'; 13 | 14 | // w3c manifest 15 | export interface Manifest { 16 | dir: 'auto' | string; 17 | lang: 'en-US' | 'fr' | string; 18 | name: string; 19 | short_name: string; 20 | description: string; 21 | categories: SpaceSeparatedList; 22 | iarc_rating_id: string; 23 | start_url: string; 24 | icons: Array; 25 | screenshots: Array; 26 | display: 'browser' | 'fullscreen' | 'standalone' | 'minimal-ui'; 27 | orientation: 28 | | 'any' 29 | | 'natural' 30 | | 'landscape' 31 | | 'portrait' 32 | | 'portrait-primary' 33 | | 'portrait-secondary' 34 | | 'landscape-primary' 35 | | 'landscape-secondary'; 36 | theme_color: HexCode; 37 | background_color: HexCode; 38 | scope: string; 39 | related_applications?: Array; 40 | prefer_related_application?: boolean; 41 | shortcuts: Array; 42 | 43 | // TODO: populate and remove 44 | [name: string]: any; 45 | } 46 | 47 | export type Categories = 'icons' | 'screenshots' | 'unset'; 48 | 49 | // Remapping of TV4 schema validation errors 50 | export interface ManifestGuidance { 51 | code: string; // w3c-schema-${tv4.errorCodes[number]} 52 | description: string; // tv4 error message 53 | platform: string; // is going to be 'all', unless overridden by underlying 54 | level: 'warning' | 'error'; // if code is not found then 'warning' 55 | member: string; // tv4 dataPath 56 | } 57 | 58 | export interface ManifestInfo { 59 | id: number; 60 | format: ManifestFormat; 61 | generatedUrl: string; 62 | content: Manifest; 63 | default: Partial; 64 | errors: Array; 65 | suggestions: Array; 66 | warnings: Array; 67 | } 68 | 69 | export type IconManifestImageResource = ManifestImageResource & 70 | PWABuilderImageResource; 71 | export type ScreenshotManifestImageResource = ManifestImageResource & 72 | PWABuilderImageResource; 73 | export interface PWABuilderImageResource { 74 | generated?: boolean; 75 | } 76 | 77 | export enum BlobCategory { 78 | icons = 'icons', 79 | screenshots = 'screenshots', 80 | } 81 | 82 | export enum ManifestFormat { 83 | w3c = 'w3c', 84 | chromeos = 'chromeos', 85 | edgeextension = 'edgeextension', 86 | windows10 = 'windows10', 87 | firefox = 'firefox', 88 | } 89 | 90 | export interface PageData { 91 | sitePage: Page; 92 | pageResponse: HTTPResponse; 93 | browser: Browser; 94 | } 95 | 96 | export interface OfflineTestData { 97 | worksOffline: boolean; 98 | } 99 | 100 | export interface ImageGeneratorManifestImageResource { 101 | src: Jimp; 102 | sizes: WidthByHeight | SpaceSeparatedList; 103 | type: SpaceSeparatedList; //use Jimp.MIME_ 104 | purpose?: W3CPurpose; 105 | } 106 | 107 | export interface ImageGeneratorImageSpec { 108 | width: number; 109 | height: number; 110 | name: string; 111 | desc: string; 112 | folder: string; 113 | } 114 | 115 | export enum ImageGeneratorSources { 116 | all = 'pwabuilderImages', 117 | android = 'androidImages', 118 | chrome = 'chromeImages', 119 | firefox = 'firefoxImages', 120 | iosImages = 'iosImages', 121 | msteams = 'msteamsImages', 122 | windows10 = 'windows10Images', 123 | windows = 'windowsImages', 124 | } 125 | 126 | export interface ZipResult { 127 | zip: JSZip; 128 | success: boolean; 129 | } -------------------------------------------------------------------------------- /utils/loadPage.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import { Page, HTTPResponse, Browser, launch } from 'puppeteer'; 3 | import { LogMessages } from './logMessages.js'; 4 | 5 | export interface LoadedPage { 6 | sitePage: Page; 7 | pageResponse: HTTPResponse; 8 | browser: Browser; 9 | } 10 | 11 | export default async function loadPage( 12 | site: string, 13 | context: Context 14 | ): Promise { 15 | let sitePage: Page; 16 | let pageResponse: HTTPResponse | null; 17 | 18 | const timeout = 115000; 19 | 20 | let browser: Browser | undefined; 21 | try { 22 | // const start = new Date().getTime(); 23 | browser = await getBrowser(context); 24 | // const elapsed = new Date().getTime() - start; 25 | // context.log('TIME ELAPSED', elapsed); 26 | sitePage = await browser.newPage(); 27 | 28 | await sitePage.setDefaultNavigationTimeout(timeout); 29 | 30 | pageResponse = await sitePage.goto(site, { 31 | waitUntil: ['domcontentloaded'], 32 | }); 33 | 34 | if (pageResponse) { 35 | return { 36 | sitePage: sitePage, 37 | pageResponse: pageResponse, 38 | browser: browser, 39 | }; 40 | } else { 41 | throw new Error('Could not get a page response'); 42 | } 43 | } catch (err) { 44 | if (browser) 45 | await closeBrowser(context, browser); 46 | return err as Error; 47 | } 48 | } 49 | 50 | export async function getBrowser(context: Context): Promise { 51 | context.log.info(LogMessages.OPENING_BROWSER); 52 | 53 | return await launch({ 54 | headless: true, 55 | // args: ['--no-sandbox', '--disable-setuid-sandbox'], 56 | }); 57 | } 58 | 59 | export async function closeBrowser( 60 | context: Context, 61 | browser: Browser 62 | ): Promise { 63 | if (browser) { 64 | context.log.info(LogMessages.CLOSING_BROWSER); 65 | 66 | try { 67 | await browser.close(); 68 | } catch (err) { 69 | console.warn('Error closing browser', err); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /utils/logMessages.ts: -------------------------------------------------------------------------------- 1 | export enum LogMessages { 2 | CLOSING_BROWSER = "Closing the browser instance", 3 | OPENING_BROWSER = "Spinning up a browser instance" 4 | } -------------------------------------------------------------------------------- /utils/mani-tests.ts: -------------------------------------------------------------------------------- 1 | import { IconManifestImageResource, Manifest } from "./interfaces.js"; 2 | 3 | export const checkShortName = (data: Manifest) => { 4 | if (data.short_name) { 5 | return true; 6 | } 7 | else { 8 | return false; 9 | } 10 | } 11 | 12 | export const checkName = (data: Manifest) => { 13 | if (data.name) { 14 | return true; 15 | } 16 | else { 17 | return false; 18 | } 19 | } 20 | 21 | export const checkDesc = (data: Manifest) => { 22 | if (data.description && data.description.length > 0) { 23 | return true; 24 | } 25 | else { 26 | return false; 27 | } 28 | } 29 | 30 | export const checkDisplay = (data: Manifest) => { 31 | if (data.display && data.display.length > 0) { 32 | return true; 33 | } 34 | else { 35 | return false; 36 | } 37 | } 38 | 39 | export const checkStartUrl = (data: Manifest) => { 40 | if (data.start_url && data.start_url.length > 0) { 41 | return true; 42 | } 43 | else { 44 | return false; 45 | } 46 | } 47 | 48 | export const checkIcons = (data: Manifest) => { 49 | if (data.icons && data.icons.length > 0) { 50 | return true; 51 | } 52 | else { 53 | return false; 54 | } 55 | } 56 | 57 | export const checkMaskableIcon = (data: Manifest) => { 58 | const hasIcons = checkIcons(data); 59 | 60 | if (hasIcons) { 61 | let hasMask = false; 62 | 63 | data.icons.forEach((icon: IconManifestImageResource) => { 64 | if (icon.purpose && icon.purpose.includes('maskable')) { 65 | hasMask = true; 66 | } 67 | }) 68 | 69 | return hasMask; 70 | } 71 | else { 72 | return false; 73 | } 74 | } 75 | 76 | export const checkScreenshots = (data: Manifest) => { 77 | if (data.screenshots && data.screenshots.length > 0) { 78 | return true; 79 | } 80 | else { 81 | return false; 82 | } 83 | } 84 | 85 | export const checkCategories = (data: Manifest) => { 86 | if (data.categories && data.categories.length > 0) { 87 | return true; 88 | } 89 | else { 90 | return false; 91 | } 92 | } 93 | 94 | export const checkRating = (data: Manifest) => { 95 | if (data.iarc_rating) { 96 | return true; 97 | } 98 | else { 99 | return false; 100 | } 101 | } 102 | 103 | export const checkRelatedApps = (data: Manifest) => { 104 | if (data.related_applications) { 105 | return true; 106 | } 107 | else { 108 | return false; 109 | } 110 | } 111 | 112 | export const checkRelatedPref = (data: Manifest) => { 113 | if (data.prefer_related_applications !== undefined && data.prefer_related_applications !== null) { 114 | return true; 115 | } 116 | else { 117 | return false; 118 | } 119 | } 120 | 121 | export const checkBackgroundColor = (data: Manifest) => { 122 | if (data.background_color) { 123 | return true; 124 | } 125 | else { 126 | return false; 127 | } 128 | } 129 | 130 | export const checkThemeColor = (data: Manifest) => { 131 | if (data.theme_color) { 132 | return true; 133 | } 134 | else { 135 | return false; 136 | } 137 | } 138 | 139 | export const checkOrientation = (data: Manifest) => { 140 | if (data.orientation) { 141 | return true; 142 | } 143 | else { 144 | return false; 145 | } 146 | } -------------------------------------------------------------------------------- /utils/manifest.ts: -------------------------------------------------------------------------------- 1 | import { BlobDownloadResponseParsed } from "@azure/storage-blob"; 2 | import { Manifest } from "./interfaces.js"; 3 | 4 | export function sanitizeName(manifest: Manifest) { 5 | let sanitizedName = manifest.short_name; 6 | sanitizedName.replace(/[^a-zA-Z0-9]/g, "_"); 7 | 8 | var currentLength; 9 | do { 10 | currentLength = sanitizedName.length; 11 | sanitizedName = sanitizedName.replace(/^[0-9]/, ""); 12 | sanitizedName = sanitizedName.replace(/^\./, ""); 13 | sanitizedName = sanitizedName.replace(/\.[0-9]/g, "."); 14 | sanitizedName = sanitizedName.replace(/\.\./g, "."); 15 | sanitizedName = sanitizedName.replace(/\.$/, ""); 16 | } while (currentLength > sanitizedName.length); 17 | 18 | if (sanitizedName.length === 0) { 19 | sanitizedName = "myPWABuilderApp"; 20 | } 21 | 22 | manifest.short_name = sanitizedName; 23 | } 24 | 25 | export function readManifestBlob( 26 | response: BlobDownloadResponseParsed 27 | ): Manifest { 28 | const streamBody: ReadableStream | any = response.readableStreamBody; 29 | 30 | return (JSON.stringify(streamToBuffer(streamBody)) as unknown) as Manifest; 31 | } 32 | 33 | async function streamToBuffer(readableStream: any) { 34 | return new Promise((resolve, reject) => { 35 | const chunks: Array = []; 36 | readableStream.on("data", (data: Buffer) => { 37 | chunks.push(data instanceof Buffer ? data : Buffer.from(data)); 38 | }); 39 | readableStream.on("end", () => { 40 | resolve(Buffer.concat(chunks)); 41 | }); 42 | readableStream.on("error", reject); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /utils/platform.ts: -------------------------------------------------------------------------------- 1 | import { SASQueryParameters } from "@azure/storage-blob"; 2 | import { 3 | IconManifestImageResource, 4 | ScreenshotManifestImageResource, 5 | } from "./interfaces.js"; 6 | 7 | type ImageKey = string; 8 | type SpaceSeparatedList = string; 9 | 10 | export interface PlatformGenerateZipOutput { 11 | success: boolean; 12 | link?: string; 13 | zipSAS?: SASQueryParameters; 14 | error?: { 15 | name: string; 16 | message: string; 17 | stack: string; 18 | }; 19 | } 20 | 21 | export interface PlatformGenerateZipInput { 22 | containerId: string; 23 | platform: PlatformId; 24 | siteUrl: string; 25 | } 26 | export interface ImageProperties 27 | extends IconManifestImageResource, 28 | ScreenshotManifestImageResource { 29 | name?: string; 30 | category?: string; 31 | width: number; 32 | height: number; 33 | size?: string; 34 | path?: string; 35 | } 36 | 37 | export enum PlatformId { 38 | web = "web", 39 | } 40 | 41 | interface PlatformImageSizes { 42 | [name: string /* PlatformId */]: Array; 43 | } 44 | 45 | export function requiredPlatformImages( 46 | platformId: string | PlatformId 47 | ): Map { 48 | return new Map( 49 | ImageSizes[platformId].map((entry: ImageProperties) => { 50 | return [ImageKey(entry), entry]; 51 | }) 52 | ); 53 | } 54 | 55 | export function ImageKey(properties: Partial): ImageKey { 56 | let category = ""; 57 | let size = ""; 58 | let purpose = ""; 59 | let name = ""; 60 | 61 | if (properties.size) { 62 | size = properties.size; 63 | } else { 64 | size = properties.width + "x" + properties.height; 65 | } 66 | 67 | if (properties.purpose && properties.purpose !== "none") { 68 | purpose = "-" + properties.purpose; 69 | } 70 | 71 | if (properties.category) { 72 | name = "-" + properties.category; 73 | } 74 | 75 | if (properties.name) { 76 | name = "-" + properties.name; 77 | } 78 | 79 | return `${size}${purpose}${category}${name}`; 80 | } 81 | 82 | export function parseImageBlobName( 83 | imageKey: ImageKey 84 | ): Partial { 85 | let [size, purpose, category, name] = imageKey.split("-"); 86 | return { 87 | size, 88 | purpose, 89 | category, 90 | name, 91 | }; 92 | } 93 | 94 | // JSON object that correlates the PWABuilder Image Sizes for the platform 95 | const ImageSizes: PlatformImageSizes = { 96 | [PlatformId.web]: [], 97 | }; 98 | -------------------------------------------------------------------------------- /utils/sas.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "@azure/functions"; 2 | import { 3 | BlobServiceClient, 4 | ContainerSASPermissions, 5 | generateBlobSASQueryParameters, 6 | } from "@azure/storage-blob"; 7 | 8 | export async function generateSASLink( 9 | containerId: string, 10 | serviceClient: BlobServiceClient, 11 | context?: Context 12 | ) { 13 | try { 14 | context?.log("creating delegate key"); 15 | const startsOn = new Date(); 16 | const expiresOn = new Date(); 17 | expiresOn.setHours(expiresOn.getHours() + 6); 18 | const delegateKey = await getUserDelegationKey( 19 | serviceClient, 20 | startsOn, 21 | expiresOn 22 | ); 23 | 24 | context?.log("create SAS query parameters"); 25 | return generateBlobSASQueryParameters( 26 | { 27 | containerName: containerId, 28 | permissions: ContainerSASPermissions.parse("r"), 29 | startsOn, 30 | expiresOn, 31 | }, 32 | delegateKey, 33 | process.env.ACCOUNT_NAME as string 34 | ); 35 | } catch (e) { 36 | context?.log("failed to create SAS credentials"); 37 | context?.log(e); 38 | } 39 | } 40 | 41 | export async function getUserDelegationKey( 42 | serviceClient: BlobServiceClient, 43 | startsOn: Date, 44 | expiresOn: Date 45 | ) { 46 | // Get Delegated SAS Key 47 | return await serviceClient.getUserDelegationKey(startsOn, expiresOn); 48 | } 49 | -------------------------------------------------------------------------------- /utils/set.ts: -------------------------------------------------------------------------------- 1 | export function setUnion(a: Set, b: Set): Array { 2 | return [...a, ...b]; 3 | } 4 | 5 | export function setIntersection(a: Set, b: Set): Array { 6 | const output: Array = []; 7 | 8 | for (const el of a) { 9 | if (b.has(el)) { 10 | output.push(el); 11 | } 12 | } 13 | 14 | return output; 15 | } 16 | 17 | export function setDifference(a: Set, b: Set): Array { 18 | const output: Array = []; 19 | 20 | for (const el of a) { 21 | if (!b.has(el)) { 22 | output.push(el); 23 | } 24 | } 25 | 26 | for (const el of b) { 27 | if (!a.has(el)) { 28 | output.push(el); 29 | } 30 | } 31 | 32 | return output; 33 | } 34 | -------------------------------------------------------------------------------- /utils/storage.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from "crypto"; 2 | import { 3 | BlobServiceClient, 4 | ContainerCreateIfNotExistsResponse, 5 | } from "@azure/storage-blob"; 6 | 7 | import ExceptionOf, { ExceptionType } from "./Exception.js"; 8 | import { Context } from "@azure/functions"; 9 | import { Manifest, Categories } from "./interfaces.js"; 10 | import { W3CPurpose, MimeType, WidthByHeight, SpaceSeparatedList } from "./w3c.js"; 11 | 12 | export interface MessageQueueConfig { 13 | storageAccount: string; 14 | queueName: string; 15 | } 16 | 17 | export function createId(siteUrl: string): string { 18 | return crypto.createHmac("md5", siteUrl).digest("hex"); 19 | } 20 | 21 | export async function createContainer( 22 | id: string, 23 | context?: Context 24 | ): Promise { 25 | const blobServiceClient = getBlobServiceClient(); 26 | const containerClient = blobServiceClient.getContainerClient(id); 27 | const createRes = await containerClient.createIfNotExists({ 28 | metadata: { 29 | id, 30 | isSiteData: "true", 31 | }, 32 | access: "blob", 33 | }); 34 | 35 | if ( 36 | !createRes.succeeded && 37 | createRes.errorCode !== "ContainerAlreadyExists" 38 | ) { 39 | throw ExceptionOf( 40 | ExceptionType.BLOB_STORAGE_FAILURE, 41 | new Error(`azure blob storage error code: ${createRes.errorCode}`) 42 | ); 43 | } 44 | return createRes; 45 | } 46 | 47 | export async function addManifestToContainer( 48 | id: string, 49 | manifest: Manifest, 50 | context?: Context 51 | ) { 52 | const manifestStr = JSON.stringify(manifest); 53 | 54 | const blobServiceClient = getBlobServiceClient(); 55 | const containerClient = blobServiceClient.getContainerClient(id); 56 | const manifestBlobClient = containerClient.getBlockBlobClient( 57 | "manifest.json" 58 | ); 59 | const response = await manifestBlobClient.upload( 60 | manifestStr, 61 | manifestStr.length, 62 | { 63 | blobHTTPHeaders: { 64 | blobContentType: "application/manifest+json", 65 | }, 66 | } 67 | ); 68 | if (response.errorCode) { 69 | throw ExceptionOf( 70 | ExceptionType.BLOB_STORAGE_FAILURE, 71 | new Error("failed to upload blob with error code: " + response.errorCode) 72 | ); 73 | } 74 | } 75 | 76 | export function getBlobServiceClient(): BlobServiceClient { 77 | const connectionString = process.env.AzureWebJobsStorage; 78 | 79 | if (connectionString) { 80 | return BlobServiceClient.fromConnectionString(connectionString); 81 | } else { 82 | throw new Error( 83 | "Connection string for AzureWebJobsStorage could not be found" 84 | ); 85 | } 86 | } 87 | 88 | export async function getManifest( 89 | containerId: string, 90 | blobServiceClient?: BlobServiceClient 91 | ): Promise { 92 | const serviceClient = blobServiceClient || getBlobServiceClient(); 93 | const containerClient = serviceClient.getContainerClient(containerId); 94 | const manifestClient = containerClient.getBlobClient("manifest.json"); 95 | 96 | return manifestClient.downloadToBuffer(); 97 | } 98 | 99 | export async function getManifestJson( 100 | containerId: string, 101 | blobServiceClient?: BlobServiceClient 102 | ): Promise { 103 | return getManifest(containerId, blobServiceClient) 104 | .then((buffer) => buffer.toString("utf8")) 105 | .then((manifestStr) => JSON.parse(manifestStr)) as Promise; 106 | } 107 | 108 | export interface TagMetaDataMap { 109 | category: Categories | string; 110 | actualSize: WidthByHeight; 111 | sizes: WidthByHeight | SpaceSeparatedList; 112 | originalUrl: string; 113 | type: MimeType; 114 | purpose: W3CPurpose; 115 | generated: "true" | "false"; 116 | } 117 | 118 | // These work the same... drives me insane why there's different declarations. 119 | type MapGestalt = 120 | | Record 121 | | { 122 | [name: string]: string; 123 | }; 124 | 125 | export function getTagMetadataProperties(gestalt: MapGestalt): TagMetaDataMap { 126 | return { 127 | category: gestalt.category ?? "unset", 128 | actualSize: gestalt.actualSize ?? "unset", 129 | sizes: gestalt.sizes ?? "unset", 130 | originalUrl: gestalt.originalUrl ?? "unset", 131 | type: gestalt.type ?? "unset", 132 | purpose: gestalt.purpose ?? "unset", 133 | generated: gestalt.generated ? "true" : "false", 134 | }; 135 | } 136 | 137 | // The client library does not accept undefined or null properties in the metadata fields, to keep consistent only add entries if defined. 138 | export function setTagMetadataProperties( 139 | tagMetaData: Partial 140 | ): MapGestalt { 141 | const output: any = {}; 142 | if (tagMetaData.category) { 143 | output.category = tagMetaData.category; 144 | } 145 | 146 | if (tagMetaData.actualSize) { 147 | output.actualSize = tagMetaData.actualSize; 148 | } 149 | 150 | if (tagMetaData.sizes) { 151 | output.sizes = tagMetaData.sizes; 152 | } 153 | 154 | if (tagMetaData.type) { 155 | output.type = tagMetaData.type; 156 | } 157 | 158 | if (tagMetaData.purpose) { 159 | output.purpose = tagMetaData.purpose; 160 | } 161 | 162 | if (tagMetaData.generated) { 163 | output.generated = tagMetaData.generated; 164 | } 165 | 166 | return output; 167 | } 168 | -------------------------------------------------------------------------------- /utils/testManifest.ts: -------------------------------------------------------------------------------- 1 | import { checkBackgroundColor, checkCategories, checkDesc, checkDisplay, checkIcons, checkMaskableIcon, checkName, checkOrientation, checkRating, checkRelatedApps, checkRelatedPref, checkScreenshots, checkShortName, checkStartUrl, checkThemeColor } from './mani-tests.js'; 2 | import { Manifest } from "./interfaces.js"; 3 | 4 | export default async function testManifest(mani: Manifest) { 5 | if (mani) { 6 | const results = { 7 | "required": { 8 | "short_name": checkShortName(mani), 9 | "name": checkName(mani), 10 | "display": checkDisplay(mani), 11 | "start_url": checkStartUrl(mani), 12 | "icons": checkIcons(mani) 13 | }, 14 | "recommended": { 15 | "screenshots": checkScreenshots(mani), 16 | "description": checkDesc(mani), 17 | "categories": checkCategories(mani), 18 | "maskable_icon": checkMaskableIcon(mani), 19 | "iarc_rating": checkRating(mani), 20 | "related_applications": checkRelatedApps(mani), 21 | "prefer_related_applications": checkRelatedPref(mani), 22 | "background_color": checkBackgroundColor(mani), 23 | "theme_color": checkThemeColor(mani), 24 | "orientation": checkOrientation(mani) 25 | } 26 | } 27 | 28 | return results; 29 | } 30 | } -------------------------------------------------------------------------------- /utils/urlLogger.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | export function logOfflineResult(url: string, offlineDetected: boolean): Promise { 4 | const args = { 5 | url: url, 6 | serviceWorkerOfflineScore: offlineDetected ? 2 : 0 7 | }; 8 | return postToUrlAnalytics(args); 9 | } 10 | 11 | export function logHttpsResult(url: string, success: boolean, score: number | null, error: string | null, startTime: Date): Promise { 12 | const detectionTimeInMs = new Date().getTime() - startTime.getTime(); 13 | const args = { 14 | url: url, 15 | httpsDetected: success, 16 | httpsScore: score, 17 | httpsDetectionError: error, 18 | httpsDetectionTimeInMs: detectionTimeInMs 19 | }; 20 | return postToUrlAnalytics(args); 21 | } 22 | 23 | function postToUrlAnalytics(args: unknown): Promise { 24 | // This environment variable is a secret, and set only in deployed environments 25 | const logApiUrl = process.env.ANALYSIS_LOG_URL; 26 | if (!logApiUrl) { 27 | return Promise.resolve(); 28 | } 29 | 30 | return fetch(logApiUrl, { 31 | method: "POST", 32 | headers: { 33 | "Content-Type": "application/json; charset=UTF-8" 34 | }, 35 | body: JSON.stringify(args) 36 | }).catch(err => console.error("Unable to POST to log analysis URL due to error", err, args)); 37 | } -------------------------------------------------------------------------------- /utils/w3c.ts: -------------------------------------------------------------------------------- 1 | export type WidthByHeight = string; 2 | export type MimeType = string; 3 | export type W3CPurpose = 4 | | 'monochrome' 5 | | 'maskable' 6 | | 'any' 7 | | SpaceSeparatedList 8 | | 'none'; 9 | export type SpaceSeparatedList = string; 10 | 11 | export interface ManifestImageResource { 12 | src: string; 13 | sizes: WidthByHeight | SpaceSeparatedList; 14 | type: MimeType | SpaceSeparatedList; //use Jimp.MIME_ 15 | purpose?: W3CPurpose; 16 | } 17 | 18 | export interface ExternalApplicationResource { 19 | platform: string; 20 | url: string; 21 | id: string; 22 | min_version: '1' | '2' | string; 23 | fingerprints: Record; 24 | } 25 | 26 | export interface ShortcutItem { 27 | name: string; 28 | short_name: string; 29 | description: string; 30 | url: string; 31 | icons: Array; 32 | } 33 | --------------------------------------------------------------------------------