| // Copyright 2014 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import '../base/common.dart'; | |
| import '../base/file_system.dart'; | |
| import '../build_info.dart'; | |
| import '../features.dart'; | |
| import '../globals.dart' as globals; | |
| import '../runner/flutter_command.dart'; | |
| import '../web/compile.dart'; | |
| import '../web/content_hash.dart'; | |
| import '../web/web_constants.dart'; | |
| import '../web/web_options.dart'; | |
| import '../web_template.dart'; | |
| import 'build.dart'; | |
| class BuildWebCommand extends BuildSubCommand { | |
| BuildWebCommand({required super.logger, required this._fileSystem, required super.verboseHelp}) { | |
| registerOptionBundles(const <OptionBundle>[ | |
| CommonBuildOptionsBundle(), | |
| BuildModeOptionsBundle(), | |
| DartCompileOptionsBundle(), | |
| WebOptionsBundle(), | |
| ]); | |
| } | |
| final FileSystem _fileSystem; | |
| @override | |
| Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{ | |
| DevelopmentArtifact.web, | |
| }; | |
| @override | |
| final name = 'web'; | |
| @override | |
| bool get hidden => !featureFlags.isWebEnabled; | |
| @override | |
| final description = 'Build a web application bundle.'; | |
| @override | |
| Future<FlutterCommandResult> runCommand() async { | |
| if (!featureFlags.isWebEnabled) { | |
| throwToolExit( | |
| '"build web" is not currently supported. To enable, run "flutter config --enable-web".', | |
| ); | |
| } | |
| final String? optimizationLevelArg = getValue(WebOptions.optimizationLevel); | |
| final int? optimizationLevel = optimizationLevelArg != null | |
| ? int.parse(optimizationLevelArg) | |
| : null; | |
| final String? dart2jsOptimizationLevelValue = getValue(WebOptions.dart2jsOptimization); | |
| final int? jsOptimizationLevel = dart2jsOptimizationLevelValue != null | |
| ? int.parse(dart2jsOptimizationLevelValue.substring(1)) | |
| : optimizationLevel; | |
| final List<String> dartDefines = extractDartDefines( | |
| defineConfigJsonMap: extractDartDefineConfigJsonMap(), | |
| ); | |
| final bool useWasm = getValue(WebOptions.wasm); | |
| // See also: RunCommandBase.webRenderer and TestCommand.webRenderer. | |
| final webRenderer = WebRendererMode.fromDartDefines(dartDefines, useWasm: useWasm); | |
| final bool sourceMaps = getValue(WebOptions.sourceMaps); | |
| final bool webContentHash = getValue(WebOptions.webContentHash); | |
| if (webContentHash && getValue(WebOptions.enableWasmDeferredLoading)) { | |
| throwToolExit( | |
| '"--web-content-hash" does not yet support deferred loading: deferred ' | |
| 'module files keep unhashed names and can be served stale from the ' | |
| 'browser cache alongside a new entrypoint. Build without ' | |
| '"--enable-wasm-deferred-loading" or without "--web-content-hash".', | |
| ); | |
| } | |
| final bool? minifyJs = getValue(WebOptions.minifyJs); | |
| final bool? minifyWasm = getValue(WebOptions.minifyWasm); | |
| final List<WebCompilerConfig> compilerConfigs; | |
| if (useWasm) { | |
| if (webRenderer != WebRendererMode.getDefault(useWasm: true)) { | |
| throwToolExit( | |
| 'Do not attempt to set a web renderer when using "--${FlutterOptions.kWebWasmFlag}"', | |
| ); | |
| } | |
| globals.logger.printBox(title: 'New feature', ''' | |
| WebAssembly compilation is new. Understand the details before deploying to production. | |
| $kWasmMoreInfo'''); | |
| compilerConfigs = <WebCompilerConfig>[ | |
| WasmCompilerConfig( | |
| optimizationLevel: optimizationLevel, | |
| stripWasm: getValue(WebOptions.stripWasm), | |
| sourceMaps: sourceMaps, | |
| webContentHash: webContentHash, | |
| minify: minifyWasm, | |
| enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), | |
| ), | |
| JsCompilerConfig( | |
| csp: getValue(WebOptions.csp), | |
| dumpInfo: getValue(WebOptions.dumpInfo), | |
| minify: minifyJs, | |
| nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), | |
| useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), | |
| optimizationLevel: jsOptimizationLevel, | |
| sourceMaps: sourceMaps, | |
| webContentHash: webContentHash, | |
| ), | |
| ]; | |
| } else { | |
| compilerConfigs = <WebCompilerConfig>[ | |
| JsCompilerConfig( | |
| csp: getValue(WebOptions.csp), | |
| dumpInfo: getValue(WebOptions.dumpInfo), | |
| minify: minifyJs, | |
| nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), | |
| useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), | |
| optimizationLevel: jsOptimizationLevel, | |
| sourceMaps: sourceMaps, | |
| webContentHash: webContentHash, | |
| renderer: webRenderer, | |
| ), | |
| if (getValue(WebOptions.wasmDryRun)) | |
| WasmCompilerConfig( | |
| optimizationLevel: optimizationLevel, | |
| stripWasm: getValue(WebOptions.stripWasm), | |
| sourceMaps: sourceMaps, | |
| webContentHash: webContentHash, | |
| minify: minifyWasm, | |
| enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), | |
| dryRun: true, | |
| ), | |
| ]; | |
| } | |
| final BuildInfo buildInfo = await getBuildInfo(); | |
| final String? baseHref = getValue(WebOptions.baseHref); | |
| final String? staticAssetsUrl = getValue(WebOptions.staticAssetsUrl); | |
| if (baseHref != null && !(baseHref.startsWith('/') && baseHref.endsWith('/'))) { | |
| throwToolExit( | |
| 'Received a --base-href value of "$baseHref"\n' | |
| '--base-href should start and end with /', | |
| ); | |
| } | |
| if (staticAssetsUrl != null && !staticAssetsUrl.endsWith('/')) { | |
| throwToolExit( | |
| 'Received a --static-assets-url value of "$staticAssetsUrl"\n' | |
| '--static-assets-url should end with /', | |
| ); | |
| } | |
| if (!project.web.existsSync()) { | |
| throwToolExit( | |
| 'This project is not configured for the web.\n' | |
| 'To configure this project for the web, run flutter create . --platforms web', | |
| ); | |
| } | |
| final File indexHtmlFile = _fileSystem.file(project.web.indexFile.path); | |
| if (indexHtmlFile.existsSync()) { | |
| final String indexHtmlContent = indexHtmlFile.readAsStringSync(); | |
| if (!indexHtmlContent.contains(kBaseHrefPlaceholder) && baseHref != null) { | |
| throwToolExit( | |
| "Couldn't find the placeholder for base href. " | |
| 'Please add `<base href="$kBaseHrefPlaceholder">` to web/index.html', | |
| ); | |
| } | |
| if (webContentHash) { | |
| final File bootstrapJsFile = project.web.directory.childFile('flutter_bootstrap.js'); | |
| _validateIndexHtmlForContentHash( | |
| indexHtmlFile, | |
| bootstrapJsFile: bootstrapJsFile.existsSync() ? bootstrapJsFile : null, | |
| ); | |
| } | |
| } | |
| final String? outputDirectoryPath = getValue(CommonOptions.outputDir); | |
| final Map<String, String> webDefines = extractWebDefines(); | |
| final webBuilder = WebBuilder( | |
| logger: globals.logger, | |
| processManager: globals.processManager, | |
| buildSystem: globals.buildSystem, | |
| fileSystem: globals.fs, | |
| flutterVersion: globals.flutterVersion, | |
| analytics: globals.analytics, | |
| ); | |
| await webBuilder.buildWeb( | |
| project, | |
| targetFile, | |
| buildInfo, | |
| getValue(WebOptions.pwaStrategy), | |
| compilerConfigs: compilerConfigs, | |
| baseHref: baseHref, | |
| staticAssetsUrl: staticAssetsUrl, | |
| outputDirectoryPath: outputDirectoryPath, | |
| webDefines: webDefines, | |
| ); | |
| // TODO(kevmoo): Ensure https://github.com/flutter/website/issues/13825 is | |
| // documented and merged before this feature is promoted to default/stable. | |
| if (webContentHash) { | |
| logger.printStatus( | |
| '\nServing tip: Configure your web host to serve "index.html" and "flutter_bootstrap.js"\n' | |
| 'with "Cache-Control: no-cache" (or revalidation) so browser clients immediately pick up new deployments.\n' | |
| 'Hashed entrypoint files (*.<hash>.*) can be served with long-term immutable caching (e.g. "Cache-Control: max-age=31536000, immutable").\n' | |
| 'When "--no-web-resources-cdn" is used, bundled "canvaskit/**" files are not content-hashed ("urlHashed: false" in "precache_manifest.json") and should not be served with "immutable" caching.\n' | |
| 'See https://docs.flutter.dev/deployment/web for caching guidance.', | |
| ); | |
| } | |
| return FlutterCommandResult.success(); | |
| } | |
| void _validateIndexHtmlForContentHash(File indexHtmlFile, {File? bootstrapJsFile}) { | |
| final String indexHtmlContent = indexHtmlFile.readAsStringSync(); | |
| final String uncommentedContent = stripHtmlAndJsComments(indexHtmlContent); | |
| if (uncommentedContent.contains('main.dart.js') || | |
| uncommentedContent.contains('loadEntrypoint')) { | |
| throwToolExit( | |
| 'Cannot build with "--web-content-hash" because web/index.html contains ' | |
| 'direct references to "main.dart.js" or the deprecated "FlutterLoader.loadEntrypoint" API.\n' | |
| 'Modern Flutter Web applications use the templated "flutter_bootstrap.js" loader script ' | |
| 'which automatically resolves content-hashed entrypoints. ' | |
| 'Please update web/index.html or run "flutter create . --platforms web" to migrate.', | |
| ); | |
| } | |
| if (!uncommentedContent.contains('flutter_bootstrap.js') && | |
| !uncommentedContent.contains('{{flutter_bootstrap_js}}') && | |
| !uncommentedContent.contains('{{flutter_build_config}}')) { | |
| throwToolExit( | |
| 'Cannot build with "--web-content-hash" because web/index.html does not ' | |
| 'reference "flutter_bootstrap.js", "{{flutter_bootstrap_js}}", or "{{flutter_build_config}}".\n' | |
| 'Modern Flutter Web applications use the templated "flutter_bootstrap.js" loader script ' | |
| 'which automatically resolves content-hashed entrypoints. ' | |
| 'Please update web/index.html or run "flutter create . --platforms web" to migrate.', | |
| ); | |
| } | |
| if (bootstrapJsFile != null) { | |
| final String bootstrapContent = bootstrapJsFile.readAsStringSync(); | |
| final String uncommentedBootstrap = stripHtmlAndJsComments(bootstrapContent); | |
| if (uncommentedBootstrap.contains('main.dart.js') || | |
| uncommentedBootstrap.contains('loadEntrypoint')) { | |
| throwToolExit( | |
| 'Cannot build with "--web-content-hash" because web/flutter_bootstrap.js contains ' | |
| 'direct references to "main.dart.js" or the deprecated "FlutterLoader.loadEntrypoint" API.\n' | |
| 'Please update web/flutter_bootstrap.js to use "{{flutter_build_config}}" and ' | |
| '"_flutter.loader.load()".', | |
| ); | |
| } | |
| if (!uncommentedBootstrap.contains('{{flutter_build_config}}')) { | |
| throwToolExit( | |
| 'Cannot build with "--web-content-hash" because web/flutter_bootstrap.js does not ' | |
| 'contain the "{{flutter_build_config}}" placeholder required to inject content-hashed ' | |
| 'entrypoint and manifest filenames.', | |
| ); | |
| } | |
| } | |
| } | |
| } |