diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts index 106e572d7128..dcc9bccc6496 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts @@ -43,6 +43,7 @@ import { getStylesConfig, } from '../../webpack/configs'; import { IndexHtmlWebpackPlugin } from '../../webpack/plugins/index-html-webpack-plugin'; +import { ServiceWorkerPlugin } from '../../webpack/plugins/service-worker-plugin'; import { createWebpackLoggingCallback } from '../../webpack/utils/stats'; import { Schema as BrowserBuilderSchema, OutputHashing } from '../browser/schema'; import { Schema } from './schema'; @@ -205,6 +206,8 @@ export function serveWebpackBrowser( webpackConfig = await transforms.webpackConfiguration(webpackConfig); } + webpackConfig.plugins ??= []; + if (browserOptions.index) { const { scripts = [], styles = [], baseHref } = browserOptions; const entrypoints = generateEntryPoints({ @@ -216,7 +219,6 @@ export function serveWebpackBrowser( isHMREnabled: !!webpackConfig.devServer?.hot, }); - webpackConfig.plugins ??= []; webpackConfig.plugins.push( new IndexHtmlWebpackPlugin({ indexPath: path.resolve(workspaceRoot, getIndexInputFile(browserOptions.index)), @@ -234,6 +236,18 @@ export function serveWebpackBrowser( ); } + if (browserOptions.serviceWorker) { + webpackConfig.plugins.push( + new ServiceWorkerPlugin({ + baseHref: browserOptions.baseHref, + root: context.workspaceRoot, + projectRoot, + outputPath: path.join(context.workspaceRoot, browserOptions.outputPath), + ngswConfigPath: browserOptions.ngswConfigPath, + }), + ); + } + return { browserOptions, webpackConfig, diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts new file mode 100644 index 000000000000..13e80c7058c6 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import fetch from 'node-fetch'; +import { concatMap, count, take, timeout } from 'rxjs/operators'; +import { serveWebpackBrowser } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { + BASE_OPTIONS, + BUILD_TIMEOUT, + DEV_SERVER_BUILDER_INFO, + describeBuilder, + setupBrowserTarget, +} from '../setup'; + +describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => { + const manifest = { + index: '/index.html', + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + resources: { + files: ['/favicon.ico', '/index.html'], + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + resources: { + files: ['/assets/**', '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)'], + }, + }, + ], + }; + + describe('Behavior: "dev-server builder serves service worker"', () => { + it('works with service worker', async () => { + setupBrowserTarget(harness, { + serviceWorker: true, + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + }); + + await harness.writeFiles({ + 'ngsw-config.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + 'src/styles.css': `body { background: url(./spectrum.png); }`, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/ngsw.json'); + + expect(result?.success).toBeTrue(); + + expect(await response?.json()).toEqual( + jasmine.objectContaining({ + configVersion: 1, + index: '/index.html', + navigationUrls: [ + { positive: true, regex: '^\\/.*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' }, + ], + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + updateMode: 'prefetch', + urls: ['/favicon.ico', '/index.html'], + cacheQueryOptions: { + ignoreVary: true, + }, + patterns: [], + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + urls: ['/assets/folder-asset.txt', '/spectrum.png'], + cacheQueryOptions: { + ignoreVary: true, + }, + patterns: [], + }, + ], + dataGroups: [], + hashTable: { + '/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01', + '/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4', + '/index.html': 'cb8ad8c81cd422699d6d831b6f25ad4481f2c90a', + '/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0', + }, + }), + ); + }); + + it('works in watch mode', async () => { + setupBrowserTarget(harness, { + serviceWorker: true, + watch: true, + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + }); + + await harness.writeFiles({ + 'ngsw-config.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + 'src/styles.css': `body { background: url(./spectrum.png); }`, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const buildCount = await harness + .execute() + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result }, index) => { + expect(result?.success).toBeTrue(); + const response = await fetch(new URL('ngsw.json', `${result?.baseUrl}`)); + const { hashTable } = await response.json(); + const hashTableEntries = Object.keys(hashTable); + + switch (index) { + case 0: + expect(hashTableEntries).toEqual([ + '/assets/folder-asset.txt', + '/favicon.ico', + '/index.html', + '/spectrum.png', + ]); + + await harness.writeFile( + 'src/assets/folder-new-asset.txt', + harness.readFile('src/assets/folder-asset.txt'), + ); + break; + + case 1: + expect(hashTableEntries).toEqual([ + '/assets/folder-asset.txt', + '/assets/folder-new-asset.txt', + '/favicon.ico', + '/index.html', + '/spectrum.png', + ]); + break; + } + }), + take(2), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(2); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular_devkit/build_angular/src/utils/service-worker.ts index af8e14d775ef..95fe08123031 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/service-worker.ts @@ -8,34 +8,31 @@ import type { Config, Filesystem } from '@angular/service-worker/config'; import * as crypto from 'crypto'; -import { createReadStream, promises as fs, constants as fsConstants } from 'fs'; +import { constants as fsConstants, promises as fsPromises } from 'fs'; import * as path from 'path'; -import { pipeline } from 'stream'; import { assertIsError } from './error'; import { loadEsmModule } from './load-esm'; class CliFilesystem implements Filesystem { - constructor(private base: string) {} + constructor(private fs: typeof fsPromises, private base: string) {} list(dir: string): Promise { return this._recursiveList(this._resolve(dir), []); } read(file: string): Promise { - return fs.readFile(this._resolve(file), 'utf-8'); + return this.fs.readFile(this._resolve(file), 'utf-8'); } - hash(file: string): Promise { - return new Promise((resolve, reject) => { - const hash = crypto.createHash('sha1').setEncoding('hex'); - pipeline(createReadStream(this._resolve(file)), hash, (error) => - error ? reject(error) : resolve(hash.read()), - ); - }); + async hash(file: string): Promise { + return crypto + .createHash('sha1') + .update(await this.fs.readFile(this._resolve(file))) + .digest('hex'); } - write(file: string, content: string): Promise { - return fs.writeFile(this._resolve(file), content); + write(_file: string, _content: string): never { + throw new Error('This should never happen.'); } private _resolve(file: string): string { @@ -44,12 +41,15 @@ class CliFilesystem implements Filesystem { private async _recursiveList(dir: string, items: string[]): Promise { const subdirectories = []; - for await (const entry of await fs.opendir(dir)) { - if (entry.isFile()) { + for (const entry of await this.fs.readdir(dir)) { + const entryPath = path.join(dir, entry); + const stats = await this.fs.stat(entryPath); + + if (stats.isFile()) { // Uses posix paths since the service worker expects URLs - items.push('/' + path.relative(this.base, path.join(dir, entry.name)).replace(/\\/g, '/')); - } else if (entry.isDirectory()) { - subdirectories.push(path.join(dir, entry.name)); + items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/')); + } else if (stats.isDirectory()) { + subdirectories.push(entryPath); } } @@ -67,6 +67,8 @@ export async function augmentAppWithServiceWorker( outputPath: string, baseHref: string, ngswConfigPath?: string, + inputputFileSystem = fsPromises, + outputFileSystem = fsPromises, ): Promise { // Determine the configuration file path const configPath = ngswConfigPath @@ -76,7 +78,7 @@ export async function augmentAppWithServiceWorker( // Read the configuration file let config: Config | undefined; try { - const configurationData = await fs.readFile(configPath, 'utf-8'); + const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8'); config = JSON.parse(configurationData) as Config; } catch (error) { assertIsError(error); @@ -101,36 +103,37 @@ export async function augmentAppWithServiceWorker( ).Generator; // Generate the manifest - const generator = new GeneratorConstructor(new CliFilesystem(outputPath), baseHref); + const generator = new GeneratorConstructor( + new CliFilesystem(outputFileSystem, outputPath), + baseHref, + ); const output = await generator.process(config); // Write the manifest const manifest = JSON.stringify(output, null, 2); - await fs.writeFile(path.join(outputPath, 'ngsw.json'), manifest); + await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), manifest); // Find the service worker package const workerPath = require.resolve('@angular/service-worker/ngsw-worker.js'); + const copy = async (src: string, dest: string): Promise => { + const resolvedDest = path.join(outputPath, dest); + + return inputputFileSystem === outputFileSystem + ? // Native FS (Builder). + inputputFileSystem.copyFile(workerPath, resolvedDest, fsConstants.COPYFILE_FICLONE) + : // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory). + outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src)); + }; + // Write the worker code - await fs.copyFile( - workerPath, - path.join(outputPath, 'ngsw-worker.js'), - fsConstants.COPYFILE_FICLONE, - ); + await copy(workerPath, 'ngsw-worker.js'); // If present, write the safety worker code - const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js'); try { - await fs.copyFile( - safetyPath, - path.join(outputPath, 'worker-basic.min.js'), - fsConstants.COPYFILE_FICLONE, - ); - await fs.copyFile( - safetyPath, - path.join(outputPath, 'safety-worker.js'), - fsConstants.COPYFILE_FICLONE, - ); + const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js'); + await copy(safetyPath, 'worker-basic.min.js'); + await copy(safetyPath, 'safety-worker.js'); } catch (error) { assertIsError(error); if (error.code !== 'ENOENT') { diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/service-worker-plugin.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/service-worker-plugin.ts new file mode 100644 index 000000000000..0e32faf3749a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/service-worker-plugin.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import type { Compiler } from 'webpack'; +import { augmentAppWithServiceWorker } from '../../utils/service-worker'; + +export interface ServiceWorkerPluginOptions { + projectRoot: string; + root: string; + outputPath: string; + baseHref?: string; + ngswConfigPath?: string; +} + +export class ServiceWorkerPlugin { + constructor(private readonly options: ServiceWorkerPluginOptions) {} + + apply(compiler: Compiler) { + compiler.hooks.done.tapPromise('angular-service-worker', async (_compilation) => { + const { projectRoot, root, baseHref = '', ngswConfigPath, outputPath } = this.options; + + await augmentAppWithServiceWorker( + projectRoot, + root, + outputPath, + baseHref, + ngswConfigPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.inputFileSystem as any).promises, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.outputFileSystem as any).promises, + ); + }); + } +}