web.dart 18.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:math';

7
import 'package:crypto/crypto.dart';
8
import 'package:package_config/package_config.dart';
9

10 11 12 13
import '../../artifacts.dart';
import '../../base/file_system.dart';
import '../../base/io.dart';
import '../../build_info.dart';
14
import '../../cache.dart';
15
import '../../convert.dart';
16
import '../../dart/language_version.dart';
17
import '../../dart/package_map.dart';
18
import '../../flutter_plugins.dart';
19
import '../../globals.dart' as globals;
20
import '../../project.dart';
21 22 23
import '../../web/file_generators/flutter_js.dart' as flutter_js;
import '../../web/file_generators/flutter_service_worker_js.dart';
import '../../web/file_generators/main_dart.dart' as main_dart;
24
import '../build_system.dart';
25
import '../depfile.dart';
26
import '../exceptions.dart';
27
import 'assets.dart';
28
import 'localizations.dart';
29
import 'shader_compiler.dart';
30 31 32 33 34 35 36 37 38

/// Whether the application has web plugins.
const String kHasWebPlugins = 'HasWebPlugins';

/// An override for the dart2js build mode.
///
/// Valid values are O1 (lowest, profile default) to O4 (highest, release default).
const String kDart2jsOptimization = 'Dart2jsOptimization';

39 40 41
/// Whether to disable dynamic generation code to satisfy csp policies.
const String kCspMode = 'cspMode';

42 43 44 45 46 47
/// Base href to set in index.html in flutter build command
const String kBaseHref = 'baseHref';

/// Placeholder for base href
const String kBaseHrefPlaceholder = r'$FLUTTER_BASE_HREF';

48
/// The caching strategy to use for service worker generation.
49
const String kServiceWorkerStrategy = 'ServiceWorkerStrategy';
50

51 52 53
/// Whether the dart2js build should output source maps.
const String kSourceMapsEnabled = 'SourceMaps';

54 55 56
/// Whether the dart2js native null assertions are enabled.
const String kNativeNullAssertions = 'NativeNullAssertions';

57 58 59 60
const String kOfflineFirst = 'offline-first';
const String kNoneWorker = 'none';

/// Convert a [value] into a [ServiceWorkerStrategy].
61
ServiceWorkerStrategy _serviceWorkerStrategyFromString(String? value) {
62 63 64 65 66 67 68 69 70
  switch (value) {
    case kNoneWorker:
      return ServiceWorkerStrategy.none;
    // offline-first is the default value for any invalid requests.
    default:
      return ServiceWorkerStrategy.offlineFirst;
  }
}

71
/// Generates an entry point for a web target.
Dan Field's avatar
Dan Field committed
72
// Keep this in sync with build_runner/resident_web_runner.dart
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
class WebEntrypointTarget extends Target {
  const WebEntrypointTarget();

  @override
  String get name => 'web_entrypoint';

  @override
  List<Target> get dependencies => const <Target>[];

  @override
  List<Source> get inputs => const <Source>[
    Source.pattern('{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/web.dart'),
  ];

  @override
  List<Source> get outputs => const <Source>[
    Source.pattern('{BUILD_DIR}/main.dart'),
  ];

  @override
  Future<void> build(Environment environment) async {
94
    final String? targetFile = environment.defines[kTargetFile];
95
    final Uri importUri = environment.fileSystem.file(targetFile).absolute.uri;
96
    // TODO(zanderso): support configuration of this file.
97
    const String packageFile = '.packages';
98
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
99
      environment.fileSystem.file(packageFile),
100
      logger: environment.logger,
101
    );
102
    final FlutterProject flutterProject = FlutterProject.current();
103
    final LanguageVersion languageVersion = determineLanguageVersion(
104 105
      environment.fileSystem.file(targetFile),
      packageConfig[flutterProject.manifest.appName],
106
      Cache.flutterRoot!,
107
    );
108

109
    // Use the PackageConfig to find the correct package-scheme import path
110 111 112 113 114 115
    // for the user application. If the application has a mix of package-scheme
    // and relative imports for a library, then importing the entrypoint as a
    // file-scheme will cause said library to be recognized as two distinct
    // libraries. This can cause surprising behavior as types from that library
    // will be considered distinct from each other.
    // By construction, this will only be null if the .packages file does not
116 117
    // have an entry for the user's application or if the main file is
    // outside of the lib/ directory.
118
    final String importedEntrypoint = packageConfig.toPackageUri(importUri)?.toString()
119
      ?? importUri.toString();
120

121 122 123 124
    await injectBuildTimePluginFiles(flutterProject, webPlatform: true, destination: environment.buildDir);
    // The below works because `injectBuildTimePluginFiles` is configured to write
    // the web_plugin_registrant.dart file alongside the generated main.dart
    const String generatedImport = 'web_plugin_registrant.dart';
125

126 127 128 129
    final String contents = main_dart.generateMainDartFile(importedEntrypoint,
      languageVersion: languageVersion,
      pluginRegistrantEntrypoint: generatedImport,
    );
130 131

    environment.buildDir.childFile('main.dart')
132
      .writeAsStringSync(contents);
133 134 135
  }
}

136
/// Compiles a web entry point with dart2js.
137
class Dart2JSTarget extends Target {
138
  const Dart2JSTarget();
139 140 141 142 143 144

  @override
  String get name => 'dart2js';

  @override
  List<Target> get dependencies => const <Target>[
145 146
    WebEntrypointTarget(),
    GenerateLocalizationsTarget(),
147 148 149 150
  ];

  @override
  List<Source> get inputs => const <Source>[
151 152 153
    Source.hostArtifact(HostArtifact.flutterWebSdk),
    Source.hostArtifact(HostArtifact.dart2jsSnapshot),
    Source.hostArtifact(HostArtifact.engineDartBinary),
154
    Source.pattern('{BUILD_DIR}/main.dart'),
155
    Source.pattern('{PROJECT_DIR}/.dart_tool/package_config_subset'),
156 157 158
  ];

  @override
159 160 161 162 163
  List<Source> get outputs => const <Source>[];

  @override
  List<String> get depfiles => const <String>[
    'dart2js.d',
164 165
  ];

166 167 168 169 170 171 172 173 174 175
  String _collectOutput(ProcessResult result) {
    final String stdout = result.stdout is List<int>
        ? utf8.decode(result.stdout as List<int>)
        : result.stdout as String;
    final String stderr = result.stderr is List<int>
        ? utf8.decode(result.stderr as List<int>)
        : result.stderr as String;
    return stdout + stderr;
  }

176 177
  @override
  Future<void> build(Environment environment) async {
178 179 180 181 182
    final String? buildModeEnvironment = environment.defines[kBuildMode];
    if (buildModeEnvironment == null) {
      throw MissingDefineException(kBuildMode, name);
    }
    final BuildMode buildMode = getBuildModeForName(buildModeEnvironment);
183
    final bool sourceMapsEnabled = environment.defines[kSourceMapsEnabled] == 'true';
184
    final bool nativeNullAssertions = environment.defines[kNativeNullAssertions] == 'true';
185
    final Artifacts artifacts = globals.artifacts!;
186
    final String librariesSpec = (artifacts.getHostArtifact(HostArtifact.flutterWebSdk) as Directory).childFile('libraries.json').path;
187
    final List<String> sharedCommandOptions = <String>[
188
      artifacts.getHostArtifact(HostArtifact.engineDartBinary).path,
189
      '--disable-dart-dev',
190
      artifacts.getHostArtifact(HostArtifact.dart2jsSnapshot).path,
191
      '--libraries-spec=$librariesSpec',
192
      ...decodeCommaSeparated(environment.defines, kExtraFrontEndOptions),
193 194
      if (nativeNullAssertions)
        '--native-null-assertions',
195 196 197 198
      if (buildMode == BuildMode.profile)
        '-Ddart.vm.profile=true'
      else
        '-Ddart.vm.product=true',
199
      for (final String dartDefine in decodeDartDefines(environment.defines, kDartDefines))
200
        '-D$dartDefine',
201 202
      if (!sourceMapsEnabled)
        '--no-source-maps',
203 204 205 206 207 208 209 210
    ];

    // Run the dart2js compilation in two stages, so that icon tree shaking can
    // parse the kernel file for web builds.
    final ProcessResult kernelResult = await globals.processManager.run(<String>[
      ...sharedCommandOptions,
      '-o',
      environment.buildDir.childFile('app.dill').path,
211
      '--packages=.dart_tool/package_config.json',
212
      '--cfe-only',
213
      environment.buildDir.childFile('main.dart').path, // dartfile
214 215
    ]);
    if (kernelResult.exitCode != 0) {
216
      throw Exception(_collectOutput(kernelResult));
217
    }
218

219
    final String? dart2jsOptimization = environment.defines[kDart2jsOptimization];
220 221 222
    final File outputJSFile = environment.buildDir.childFile('main.dart.js');
    final bool csp = environment.defines[kCspMode] == 'true';

223
    final ProcessResult javaScriptResult = await environment.processManager.run(<String>[
224 225 226 227
      ...sharedCommandOptions,
      if (dart2jsOptimization != null) '-$dart2jsOptimization' else '-O4',
      if (buildMode == BuildMode.profile) '--no-minify',
      if (csp) '--csp',
228
      '-o',
229 230
      outputJSFile.path,
      environment.buildDir.childFile('app.dill').path, // dartfile
231
    ]);
232
    if (javaScriptResult.exitCode != 0) {
233
      throw Exception(_collectOutput(javaScriptResult));
234
    }
235
    final File dart2jsDeps = environment.buildDir
236
      .childFile('app.dill.deps');
237
    if (!dart2jsDeps.existsSync()) {
238
      globals.printWarning('Warning: dart2js did not produced expected deps list at '
239 240 241
        '${dart2jsDeps.path}');
      return;
    }
242 243 244 245 246
    final DepfileService depfileService = DepfileService(
      fileSystem: globals.fs,
      logger: globals.logger,
    );
    final Depfile depfile = depfileService.parseDart2js(
247
      environment.buildDir.childFile('app.dill.deps'),
248
      outputJSFile,
249
    );
250 251 252 253
    depfileService.writeToFile(
      depfile,
      environment.buildDir.childFile('dart2js.d'),
    );
254 255 256
  }
}

257
/// Unpacks the dart2js compilation and resources to a given output directory.
258
class WebReleaseBundle extends Target {
259
  const WebReleaseBundle();
260 261 262 263 264

  @override
  String get name => 'web_release_bundle';

  @override
265 266
  List<Target> get dependencies => const <Target>[
    Dart2JSTarget(),
267 268 269 270 271
  ];

  @override
  List<Source> get inputs => const <Source>[
    Source.pattern('{BUILD_DIR}/main.dart.js'),
272
    Source.pattern('{PROJECT_DIR}/pubspec.yaml'),
273 274 275 276 277
  ];

  @override
  List<Source> get outputs => const <Source>[
    Source.pattern('{OUTPUT_DIR}/main.dart.js'),
278 279 280 281 282
  ];

  @override
  List<String> get depfiles => const <String>[
    'dart2js.d',
283 284
    'flutter_assets.d',
    'web_resources.d',
285 286 287 288
  ];

  @override
  Future<void> build(Environment environment) async {
289
    for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType<File>()) {
290 291 292 293 294 295
      final String basename = globals.fs.path.basename(outputFile.path);
      if (!basename.contains('main.dart.js')) {
        continue;
      }
      // Do not copy the deps file.
      if (basename.endsWith('.deps')) {
296 297 298
        continue;
      }
      outputFile.copySync(
299
        environment.outputDir.childFile(globals.fs.path.basename(outputFile.path)).path
300 301
      );
    }
302

303
    createVersionFile(environment, environment.defines);
304 305
    final Directory outputDirectory = environment.outputDir.childDirectory('assets');
    outputDirectory.createSync(recursive: true);
306 307 308 309
    final Depfile depfile = await copyAssets(
      environment,
      environment.outputDir.childDirectory('assets'),
      targetPlatform: TargetPlatform.web_javascript,
310
      shaderTarget: ShaderTarget.sksl,
311
    );
312 313 314 315 316 317 318 319
    final DepfileService depfileService = DepfileService(
      fileSystem: globals.fs,
      logger: globals.logger,
    );
    depfileService.writeToFile(
      depfile,
      environment.buildDir.childFile('flutter_assets.d'),
    );
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337

    final Directory webResources = environment.projectDir
      .childDirectory('web');
    final List<File> inputResourceFiles = webResources
      .listSync(recursive: true)
      .whereType<File>()
      .toList();

    // Copy other resource files out of web/ directory.
    final List<File> outputResourcesFiles = <File>[];
    for (final File inputFile in inputResourceFiles) {
      final File outputFile = globals.fs.file(globals.fs.path.join(
        environment.outputDir.path,
        globals.fs.path.relative(inputFile.path, from: webResources.path)));
      if (!outputFile.parent.existsSync()) {
        outputFile.parent.createSync(recursive: true);
      }
      outputResourcesFiles.add(outputFile);
338 339 340
      // insert a random hash into the requests for service_worker.js. This is not a content hash,
      // because it would need to be the hash for the entire bundle and not just the resource
      // in question.
341 342
      if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') {
        final String randomHash = Random().nextInt(4294967296).toString();
343
        String resultString = inputFile.readAsStringSync()
344 345 346 347 348 349
          .replaceFirst(
            'var serviceWorkerVersion = null',
            "var serviceWorkerVersion = '$randomHash'",
          )
          // This is for legacy index.html that still use the old service
          // worker loading mechanism.
350 351 352 353
          .replaceFirst(
            "navigator.serviceWorker.register('flutter_service_worker.js')",
            "navigator.serviceWorker.register('flutter_service_worker.js?v=$randomHash')",
          );
354 355
        final String? baseHref = environment.defines[kBaseHref];
        if (resultString.contains(kBaseHrefPlaceholder) && baseHref == null) {
356
          resultString = resultString.replaceAll(kBaseHrefPlaceholder, '/');
357 358
        } else if (resultString.contains(kBaseHrefPlaceholder) && baseHref != null) {
          resultString = resultString.replaceAll(kBaseHrefPlaceholder, baseHref);
359
        }
360 361 362 363
        outputFile.writeAsStringSync(resultString);
        continue;
      }
      inputFile.copySync(outputFile.path);
364 365
    }
    final Depfile resourceFile = Depfile(inputResourceFiles, outputResourcesFiles);
366 367 368 369
    depfileService.writeToFile(
      resourceFile,
      environment.buildDir.childFile('web_resources.d'),
    );
370
  }
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389

  /// Create version.json file that contains data about version for package_info
  void createVersionFile(Environment environment, Map<String, String> defines) {
    final Map<String, dynamic> versionInfo =
        jsonDecode(FlutterProject.current().getVersionInfo())
            as Map<String, dynamic>;

    if (defines.containsKey(kBuildNumber)) {
      versionInfo['build_number'] = defines[kBuildNumber];
    }

    if (defines.containsKey(kBuildName)) {
      versionInfo['version'] = defines[kBuildName];
    }

    environment.outputDir
        .childFile('version.json')
        .writeAsStringSync(jsonEncode(versionInfo));
  }
390
}
391

392 393 394 395 396 397
/// Static assets provided by the Flutter SDK that do not change, such as
/// CanvasKit.
///
/// These assets can be cached forever and are only invalidated when the
/// Flutter SDK is upgraded to a new version.
class WebBuiltInAssets extends Target {
398
  const WebBuiltInAssets(this.fileSystem, this.cache);
399 400

  final FileSystem fileSystem;
401
  final Cache cache;
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419

  @override
  String get name => 'web_static_assets';

  @override
  List<Target> get dependencies => const <Target>[];

  @override
  List<String> get depfiles => const <String>[];

  @override
  List<Source> get inputs => const <Source>[];

  @override
  List<Source> get outputs => const <Source>[];

  @override
  Future<void> build(Environment environment) async {
420 421 422 423 424 425 426 427 428
    // TODO(yjbanov): https://github.com/flutter/flutter/issues/52588
    //
    // Update this when we start building CanvasKit from sources. In the
    // meantime, get the Web SDK directory from cache rather than through
    // Artifacts. The latter is sensitive to `--local-engine`, which changes
    // the directory to point to ENGINE/src/out. However, CanvasKit is not yet
    // built as part of the engine, but fetched from CIPD, and so it won't be
    // found in ENGINE/src/out.
    final Directory flutterWebSdk = cache.getWebSdkDirectory();
429 430 431 432 433 434
    final Directory canvasKitDirectory = flutterWebSdk.childDirectory('canvaskit');
    for (final File file in canvasKitDirectory.listSync(recursive: true).whereType<File>()) {
      final String relativePath = fileSystem.path.relative(file.path, from: canvasKitDirectory.path);
      final String targetPath = fileSystem.path.join(environment.outputDir.path, 'canvaskit', relativePath);
      file.copySync(targetPath);
    }
435 436 437 438

    // Write the flutter.js file
    final File flutterJsFile = environment.outputDir.childFile('flutter.js');
    flutterJsFile.writeAsStringSync(flutter_js.generateFlutterJsFile());
439 440 441
  }
}

442 443
/// Generate a service worker for a web target.
class WebServiceWorker extends Target {
444
  const WebServiceWorker(this.fileSystem, this.cache);
445 446

  final FileSystem fileSystem;
447
  final Cache cache;
448 449 450 451 452

  @override
  String get name => 'web_service_worker';

  @override
453
  List<Target> get dependencies => <Target>[
454 455
    const Dart2JSTarget(),
    const WebReleaseBundle(),
456
    WebBuiltInAssets(fileSystem, cache),
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
  ];

  @override
  List<String> get depfiles => const <String>[
    'service_worker.d',
  ];

  @override
  List<Source> get inputs => const <Source>[];

  @override
  List<Source> get outputs => const <Source>[];

  @override
  Future<void> build(Environment environment) async {
    final List<File> contents = environment.outputDir
      .listSync(recursive: true)
      .whereType<File>()
      .where((File file) => !file.path.endsWith('flutter_service_worker.js')
        && !globals.fs.path.basename(file.path).startsWith('.'))
      .toList();
478 479 480 481

    final Map<String, String> urlToHash = <String, String>{};
    for (final File file in contents) {
      // Do not force caching of source maps.
482 483
      if (file.path.endsWith('main.dart.js.map') ||
        file.path.endsWith('.part.js.map')) {
484 485 486 487 488 489 490 491 492
        continue;
      }
      final String url = globals.fs.path.toUri(
        globals.fs.path.relative(
          file.path,
          from: environment.outputDir.path),
        ).toString();
      final String hash = md5.convert(await file.readAsBytes()).toString();
      urlToHash[url] = hash;
493 494 495 496
      // Add an additional entry for the base URL.
      if (globals.fs.path.basename(url) == 'index.html') {
        urlToHash['/'] = hash;
      }
497 498
    }

499 500 501
    final File serviceWorkerFile = environment.outputDir
      .childFile('flutter_service_worker.js');
    final Depfile depfile = Depfile(contents, <File>[serviceWorkerFile]);
502
    final ServiceWorkerStrategy serviceWorkerStrategy = _serviceWorkerStrategyFromString(
503 504 505 506 507 508 509 510 511 512 513 514 515 516
      environment.defines[kServiceWorkerStrategy],
    );
    final String serviceWorker = generateServiceWorker(
      urlToHash,
      <String>[
        'main.dart.js',
        'index.html',
        if (urlToHash.containsKey('assets/AssetManifest.json'))
          'assets/AssetManifest.json',
        if (urlToHash.containsKey('assets/FontManifest.json'))
          'assets/FontManifest.json',
      ],
      serviceWorkerStrategy: serviceWorkerStrategy,
    );
517 518
    serviceWorkerFile
      .writeAsStringSync(serviceWorker);
519 520 521 522 523 524 525 526
    final DepfileService depfileService = DepfileService(
      fileSystem: globals.fs,
      logger: globals.logger,
    );
    depfileService.writeToFile(
      depfile,
      environment.buildDir.childFile('service_worker.d'),
    );
527 528
  }
}