From d07e736f17a8dc0451adf581281b7221227785f2 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Fri, 18 Jun 2021 00:13:10 +0300 Subject: [PATCH] build(docs-infra): auto-generate SW `navigationUrls` from Firebase config (#42452) Previously, redirects had to be configured in both the Firebase config (`firebase.json`) and the ServiceWorker config (`ngsw-config.json`). This made it challenging to correctly configure redirects, since one had to understand the different formats of the two configs, and was also prone to getting out-of-sync configs. This commit simplifies the process of adding redirects by removing the need to update the ServiceWorker config (`ngsw-config.json`) and keep it in sync with the Firebase config (`firebase.json`). Instead the ServiceWorker `navigationUrls` are automatically generated from the list of redirects in the Firebase config. NOTE: Currently, the automatic generation only supports the limited set of patterns that are necessary to translate the existing redirects. It can be made more sophisticated in the future, should the need arise. PR Close #42452 --- aio/angular.json | 12 ++- aio/firebase.json | 6 -- aio/ngsw-config.json | 143 ------------------------- aio/ngsw-config.template.json | 83 ++++++++++++++ aio/package.json | 3 +- aio/scripts/build-ngsw-config.js | 85 +++++++++++++++ aio/src/index.html | 2 +- aio/tests/deployment/shared/helpers.ts | 2 +- 8 files changed, 182 insertions(+), 154 deletions(-) delete mode 100644 aio/ngsw-config.json create mode 100644 aio/ngsw-config.template.json create mode 100644 aio/scripts/build-ngsw-config.js diff --git a/aio/angular.json b/aio/angular.json index 2afa8f3df7..513d7eef7f 100644 --- a/aio/angular.json +++ b/aio/angular.json @@ -32,6 +32,7 @@ "index": "src/index.html", "main": "src/main.ts", "polyfills": "src/polyfills.ts", + "ngswConfigPath": "src/generated/ngsw-config.json", "tsConfig": "tsconfig.app.json", "webWorkerTsConfig": "tsconfig.worker.json", "optimization": { @@ -47,9 +48,16 @@ "namedChunks": true, "assets": [ "src/assets", - "src/generated", "src/pwa-manifest.json", - "src/google385281288605d160.html" + "src/google385281288605d160.html", + { + "input": "src/generated", + "output": "generated", + "glob": "**", + "ignore": [ + "ngsw-config.json" + ] + } ], "styles": [ "src/styles/main.scss", diff --git a/aio/firebase.json b/aio/firebase.json index c1e3e90b26..2e61ba4448 100644 --- a/aio/firebase.json +++ b/aio/firebase.json @@ -4,12 +4,6 @@ "public": "dist", "cleanUrls": true, "redirects": [ - ////////////////////////////////////////////////////////////////////////////////////////////// - // README: - // Redirects must also be handled by the ServiceWorker. If you add a redirect rule here, - // make sure it is compatible with the configuration in `ngsw-config.json`. - ////////////////////////////////////////////////////////////////////////////////////////////// - // A random bad indexed page that used `api/api` {"type": 301, "source": "/api/api/:rest*", "destination": "/api/:rest*"}, diff --git a/aio/ngsw-config.json b/aio/ngsw-config.json deleted file mode 100644 index 10555ab0ca..0000000000 --- a/aio/ngsw-config.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "index": "/index.html", - "assetGroups": [ - { - "name": "app-shell", - "installMode": "prefetch", - "updateMode": "prefetch", - "resources": { - "files": [ - "/index.html", - "/pwa-manifest.json", - "/assets/images/favicons/favicon.ico", - "/assets/js/*.js", - "/*.css", - "/*.js", - "!/*-es5*.js" - ], - "urls": [ - "https://fonts.googleapis.com/**", - "https://fonts.gstatic.com/s/**" - ] - } - }, - { - "name": "assets-eager", - "installMode": "prefetch", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/images/**", - "/generated/images/marketing/**", - "!/assets/images/favicons/**", - "!/**/_unused/**" - ] - } - }, - { - "name": "assets-lazy", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/images/favicons/**", - "/generated/js/custom-elements-es5-polyfills.js", - "/*-es5*.js", - "!/**/_unused/**" - ] - } - }, - { - "name": "docs-index", - "installMode": "prefetch", - "updateMode": "prefetch", - "resources": { - "files": [ - "/generated/*.json", - "/generated/docs/*.json", - "/generated/docs/api/api-list.json", - "/generated/docs/app/search-data.json" - ] - } - }, - { - "name": "docs-lazy", - "installMode": "lazy", - "updateMode": "lazy", - "resources": { - "files": [ - "/generated/docs/**/*.json", - "/generated/images/**", - "!/**/_unused/**" - ] - } - } - ], - "navigationUrls": [ - "/**", - "!/**/*.*", - "!/**/*__*", - "!/**/*__*/**", - "!/**/stackblitz/{0,1}", - "!/**/AnimationStateDeclarationMetadata*", - "!/**/CORE_DIRECTIVES*", - "!/**/DirectiveMetadata-*", - "!/**/HTTP_PROVIDERS*", - "!/**/NgFor-*", - "!/**/OptionalMetadata-*", - "!/**/PLATFORM_PIPES*", - "!/**/api/common/Control*", - "!/**/api/common/ControlGroup*", - "!/**/api/common/NgModel/{0,1}", - "!/**/api/common/SelectControlValueAccessor-*", - "!/**/api/common/index/MaxLengthValidator-*", - "!/**/cookbook/ts-to-js*", - "!/api/*/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", - "!/api/*/testing/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", - "!/api/*/testing/index/*", - "!/api/animate/**", - "!/api/api/**", - "!/api/http/**", - "!/api/http/{0,1}", - "!/api/platform-browser/AnimationDriver/{0,1}", - "!/api/testing/*-*", - "!/api/upgrade/*/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", - "!/api/upgrade/*/index/*", - "!/config/app-package-json/{0,1}", - "!/config/solution-tsconfig/{0,1}", - "!/config/tsconfig/{0,1}", - "!/devtools/{0,1}", - "!/docs/*/latest/**", - "!/docs/*/latest/{0,1}", - "!/docs/latest/**", - "!/docs/styleguide*", - "!/docs/styleguide/{0,1}", - "!/getting-started/**", - "!/getting-started/{0,1}", - "!/guide/bazel/{0,1}", - "!/guide/change-log/{0,1}", - "!/guide/cli-quickstart/{0,1}", - "!/guide/displaying-data/{0,1}", - "!/guide/learning-angular*", - "!/guide/metadata/{0,1}", - "!/guide/ngmodule/{0,1}", - "!/guide/quickstart/{0,1}", - "!/guide/service-worker-comm/{0,1}", - "!/guide/service-worker-configref/{0,1}", - "!/guide/service-worker-getstart/{0,1}", - "!/guide/setup-systemjs-anatomy/{0,1}", - "!/guide/setup/{0,1}", - "!/guide/updating-to-version-10/{0,1}", - "!/guide/updating-to-version-11/{0,1}", - "!/guide/webpack/{0,1}", - "!/news*", - "!/start/data/{0,1}", - "!/start/deployment/{0,1}", - "!/start/forms/{0,1}", - "!/start/routing/{0,1}", - "!/strict/{0,1}", - "!/styleguide/{0,1}", - "!/testing/**", - "!/testing/{0,1}" - ] -} diff --git a/aio/ngsw-config.template.json b/aio/ngsw-config.template.json new file mode 100644 index 0000000000..f28aceefde --- /dev/null +++ b/aio/ngsw-config.template.json @@ -0,0 +1,83 @@ +{ + "index": "/index.html", + "assetGroups": [ + { + "name": "app-shell", + "installMode": "prefetch", + "updateMode": "prefetch", + "resources": { + "files": [ + "/index.html", + "/pwa-manifest.json", + "/assets/images/favicons/favicon.ico", + "/assets/js/*.js", + "/*.css", + "/*.js", + "!/*-es5*.js" + ], + "urls": [ + "https://fonts.googleapis.com/**", + "https://fonts.gstatic.com/s/**" + ] + } + }, + { + "name": "assets-eager", + "installMode": "prefetch", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/images/**", + "/generated/images/marketing/**", + "!/assets/images/favicons/**", + "!/**/_unused/**" + ] + } + }, + { + "name": "assets-lazy", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/images/favicons/**", + "/generated/js/custom-elements-es5-polyfills.js", + "/*-es5*.js", + "!/**/_unused/**" + ] + } + }, + { + "name": "docs-index", + "installMode": "prefetch", + "updateMode": "prefetch", + "resources": { + "files": [ + "/generated/*.json", + "/generated/docs/*.json", + "/generated/docs/api/api-list.json", + "/generated/docs/app/search-data.json" + ] + } + }, + { + "name": "docs-lazy", + "installMode": "lazy", + "updateMode": "lazy", + "resources": { + "files": [ + "/generated/docs/**/*.json", + "/generated/images/**", + "!/**/_unused/**" + ] + } + } + ], + "navigationUrls": [ + "/**", + "!/**/*.*", + "!/**/*__*", + "!/**/*__*/**", + "!/**/stackblitz/{0,1}" + ] +} diff --git a/aio/package.json b/aio/package.json index 3d3bcb2f97..448c547162 100644 --- a/aio/package.json +++ b/aio/package.json @@ -71,10 +71,11 @@ "~~audit-web-app": "node scripts/audit-web-app", "~~check-env": "node scripts/check-environment", "~~clean-generated": "node --eval \"require('shelljs').rm('-rf', 'src/generated')\"", - "pre~~build": "yarn ~~build-ce-es5-polyfills", + "pre~~build": "yarn ~~build-ce-es5-polyfills && yarn ~~build-ngsw-config", "~~build": "ng build --configuration=stable", "post~~build": "yarn build-404-page", "~~build-ce-es5-polyfills": "esbuild src/custom-elements-es5-polyfills.js --bundle --minify | swc --config=minify=true --filename=custom-elements-es5-polyfills.js --out-file=src/generated/js/custom-elements-es5-polyfills.js --no-swcrc", + "~~build-ngsw-config": "node scripts/build-ngsw-config", "~~light-server": "light-server --bind=localhost --historyindex=/index.html --no-reload" }, "//engines-comment": "Keep this in sync with /package.json and /aio/tools/examples/shared/package.json", diff --git a/aio/scripts/build-ngsw-config.js b/aio/scripts/build-ngsw-config.js new file mode 100644 index 0000000000..2cf16009f8 --- /dev/null +++ b/aio/scripts/build-ngsw-config.js @@ -0,0 +1,85 @@ +// Imports +const {basename, dirname, resolve: resolvePath} = require('canonical-path'); +const {mkdirSync, readFileSync, writeFileSync} = require('fs'); +const {parse: parseJson} = require('json5'); + + +// Constants +const FIREBASE_CONFIG_PATH = resolvePath(__dirname, '../firebase.json'); +const NGSW_CONFIG_TEMPLATE_PATH = resolvePath(__dirname, '../ngsw-config.template.json'); +const NGSW_CONFIG_OUTPUT_PATH = resolvePath(__dirname, '../src/generated/ngsw-config.json'); + +// Run +_main(); + +// Helpers +function _main() { + const firebaseConfig = readJson(FIREBASE_CONFIG_PATH); + const ngswConfig = readJson(NGSW_CONFIG_TEMPLATE_PATH); + + const firebaseRedirects = firebaseConfig.hosting.redirects; + + // Check that there are no regex-based redirects. + const regexBasedRedirects = firebaseRedirects.filter(({regex}) => regex !== undefined); + if (regexBasedRedirects.length > 0) { + throw new Error( + 'The following redirects use `regex`, which is currently not supported by ' + + `${basename(__filename)}:` + + regexBasedRedirects.map(x => `\n - ${JSON.stringify(x)}`).join('')); + } + + // Check that there are no unsupported glob characters/patterns. + const redirectsWithUnsupportedGlobs = firebaseRedirects + .filter(({source}) => !/^(?:[/\w\-.*]|:\w+|@\([\w\-.|]+\))+$/.test(source)); + if (redirectsWithUnsupportedGlobs.length > 0) { + throw new Error( + 'The following redirects use glob characters/patterns that are currently not supported by ' + + `${basename(__filename)}:` + + redirectsWithUnsupportedGlobs.map(x => `\n - ${JSON.stringify(x)}`).join('')); + } + + // Compute additional ignore glob patterns to be added to `navigationUrls`. + const additionalNavigationUrls = firebaseRedirects + // Grab the redirect source glob. + .map(({source}) => source) + // Ignore redirects for files (since these are already ignored by the SW). + .filter(glob => /\/[^/.]*$/.test(glob)) + // Convert each Firebase-specific glob to a SW-compatible glob. + .map(glob => `!${glob.replace(/:\w+/g, '*').replace(/@(\([\w\-.|]+\))/g, '$1')}`) + // Add optional trailing `/` for globs that don't end with `*`. + .map(glob => /\w$/.test(glob) ? `${glob}\/{0,1}` : glob) + // Remove more specific globs that are covered by more generic ones. + .filter((glob, _i, globs) => !isGlobRedundant(glob, globs)) + // Sort the generated globs alphabetically. + .sort(); + + // Add the additional `navigationUrls` globs and save the config. + ngswConfig.navigationUrls.push(...additionalNavigationUrls); + + mkdirSync(dirname(NGSW_CONFIG_OUTPUT_PATH), {recursive: true}); + writeJson(NGSW_CONFIG_OUTPUT_PATH, ngswConfig); +} + +function isGlobRedundant(glob, globs) { + // Find all globs that could cover other globs. + // For simplicity, we only consider globs ending with `/**`. + const genericGlobs = globs.filter(g => g.endsWith('/**')); + + // A glob is redundant if it starts with the path of a potentially generic glob (excluding the + // trailing `**`) followed by a word character or a `*`. + // For example, `/foo/bar/baz` is covered (and thus made redundant) by `/foo/**`, but `/foo/{0,1}` + // is not. + return genericGlobs.some(g => { + const pathPrefix = g.slice(0, -2); + return (glob !== g) && glob.startsWith(pathPrefix) && + /^[\w*]/.test(glob.slice(pathPrefix.length)); + }); +} + +function readJson(filePath) { + return parseJson(readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, obj) { + return writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`); +} diff --git a/aio/src/index.html b/aio/src/index.html index 46ef427ffa..2444bf4735 100644 --- a/aio/src/index.html +++ b/aio/src/index.html @@ -24,7 +24,7 @@ - + diff --git a/aio/tests/deployment/shared/helpers.ts b/aio/tests/deployment/shared/helpers.ts index fd0c0a6ba6..e439e985cf 100644 --- a/aio/tests/deployment/shared/helpers.ts +++ b/aio/tests/deployment/shared/helpers.ts @@ -19,7 +19,7 @@ export function getRedirector() { } export function getSwNavigationUrlChecker() { - const config = loadJson(`${AIO_DIR}/ngsw-config.json`); + const config = loadJson(`${AIO_DIR}/src/generated/ngsw-config.json`); const navigationUrlSpecs = processNavigationUrls('', config.navigationUrls); const includePatterns = navigationUrlSpecs