| // 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 'dart:typed_data'; |
| |
| import 'package:crypto/crypto.dart' as crypto; |
| import 'package:path/path.dart' as p; // flutter_ignore: package_path_import |
| import 'package:standard_message_codec/standard_message_codec.dart'; |
| |
| import '../base/common.dart'; |
| import '../base/file_system.dart'; |
| import '../convert.dart'; |
| |
| const List<String> _kKnownHashedExtensions = <String>[ |
| '.js.map', |
| '.wasm.map', |
| '.mjs.map', |
| '.js', |
| '.wasm', |
| '.mjs', |
| ]; |
| |
| const Set<String> _kManifestAssetRelativePaths = <String>{ |
| 'AssetManifest.json', |
| 'AssetManifest.bin', |
| 'AssetManifest.bin.json', |
| 'FontManifest.json', |
| }; |
| |
| /// Result of content-hashing the files and manifests in `build/web/assets/`. |
| class WebAssetHashResult { |
| const WebAssetHashResult({ |
| required this.renamedFiles, |
| this.assetManifestBinJson, |
| this.fontManifestJson, |
| this.extraAssets = const <String, String>{}, |
| }); |
| |
| /// Mapping from original file paths (and normalized paths) to their renamed |
| /// content-hashed [File] handles on disk. |
| final Map<String, File> renamedFiles; |
| |
| /// The content-hashed filename of `AssetManifest.bin.json` (for example, |
| /// `AssetManifest.bin.a1b2c3d4.json`), if present. |
| final String? assetManifestBinJson; |
| |
| /// The content-hashed filename of `FontManifest.json` (for example, |
| /// `FontManifest.e5f60718.json`), if present. |
| final String? fontManifestJson; |
| |
| /// Non-manifest files in `build/web/assets/` (such as `NOTICES`, framework |
| /// shaders not listed in `AssetManifest.bin`, `AssetManifest.bin`, and |
| /// `AssetManifest.json`) mapped from their unhashed relative path to their |
| /// content-hashed relative path. |
| final Map<String, String> extraAssets; |
| } |
| |
| String computeHashedBasename(String oldBasename, String contentHash, FileSystem fileSystem) { |
| final String doubleExt = fileSystem.path.extension(oldBasename, 2); |
| final String ext = _kKnownHashedExtensions.contains(doubleExt) |
| ? doubleExt |
| : fileSystem.path.extension(oldBasename); |
| if (ext.isNotEmpty) { |
| final String stem = oldBasename.substring(0, oldBasename.length - ext.length); |
| return '$stem.$contentHash$ext'; |
| } |
| return '$oldBasename.$contentHash'; |
| } |
| |
| WebAssetHashResult hashWebAssets(Directory assetsDir) { |
| final renamedFileMap = <String, File>{}; |
| if (!assetsDir.existsSync()) { |
| return WebAssetHashResult(renamedFiles: renamedFileMap); |
| } |
| |
| final FileSystem fileSystem = assetsDir.fileSystem; |
| final List<File> files = assetsDir.listSync(recursive: true).whereType<File>().toList() |
| ..sort((File a, File b) => a.path.compareTo(b.path)); |
| final rawRenamedAssets = <String, String>{}; |
| final renamedAssets = <String, String>{}; |
| final decodedRenamedAssets = <String, String>{}; |
| |
| String decodeSafe(String value) { |
| try { |
| return Uri.decodeFull(value); |
| } on ArgumentError { |
| return value; |
| } |
| } |
| |
| String hashAndRenameFile(File file, String posixRelativePath) { |
| final String relativePath = fileSystem.path.relative(file.path, from: assetsDir.path); |
| final List<String> segments = fileSystem.path.split(relativePath); |
| final String basename = fileSystem.path.basename(file.path); |
| final String contentHash = crypto.sha256 |
| .convert(file.readAsBytesSync()) |
| .toString() |
| .substring(0, 8); |
| final String newBasename = computeHashedBasename(basename, contentHash, fileSystem); |
| |
| final newSegments = <String>[...segments.sublist(0, segments.length - 1), newBasename]; |
| final String newRelativePath = fileSystem.path.joinAll(newSegments); |
| final String posixNewPath = p.posix.joinAll(newSegments); |
| final String decodedNewPath = decodeSafe(posixNewPath); |
| |
| // Rename the physical file on disk. |
| final String newPath = fileSystem.path.join(assetsDir.path, newRelativePath); |
| final String oldPath = file.path; |
| final String normalizedOldPath = fileSystem.path.normalize(oldPath); |
| file.renameSync(newPath); |
| final File newFile = fileSystem.file(newPath); |
| renamedFileMap[oldPath] = newFile; |
| renamedFileMap[normalizedOldPath] = newFile; |
| |
| // Map POSIX paths: |
| // - `renamedAssets` maps to the on-disk `%20`-encoded path (used by FontManifest.json). |
| // - `decodedRenamedAssets` maps to the `Uri.decodeFull` path (used by AssetManifest.bin / |
| // AssetManifest.json, matching `_createAssetManifest` in `asset.dart`). |
| rawRenamedAssets[posixRelativePath] = posixNewPath; |
| renamedAssets[posixRelativePath] = posixNewPath; |
| decodedRenamedAssets[posixRelativePath] = decodedNewPath; |
| |
| final String decodedOld = decodeSafe(posixRelativePath); |
| renamedAssets[decodedOld] = posixNewPath; |
| decodedRenamedAssets[decodedOld] = decodedNewPath; |
| |
| final String encodedOld = Uri.encodeFull(posixRelativePath); |
| renamedAssets[encodedOld] = posixNewPath; |
| decodedRenamedAssets[encodedOld] = decodedNewPath; |
| |
| return posixNewPath; |
| } |
| |
| // Pass 1: Hash all physical asset files (including shaders and NOTICES) |
| // except the manifest files themselves, whose contents depend on Pass 1 hashes. |
| for (final file in files) { |
| final String relativePath = fileSystem.path.relative(file.path, from: assetsDir.path); |
| final List<String> segments = fileSystem.path.split(relativePath); |
| final String posixRelativePath = p.posix.joinAll(segments); |
| |
| if (_kManifestAssetRelativePaths.contains(posixRelativePath)) { |
| continue; |
| } |
| |
| hashAndRenameFile(file, posixRelativePath); |
| } |
| |
| final manifestCoveredTargets = <String>{}; |
| void recordManifestTarget(String target) { |
| manifestCoveredTargets.add(target); |
| manifestCoveredTargets.add(Uri.encodeFull(target)); |
| manifestCoveredTargets.add(Uri(path: Uri.encodeFull(target)).path); |
| final String decoded = decodeSafe(target); |
| manifestCoveredTargets.add(decoded); |
| manifestCoveredTargets.add(Uri.encodeFull(decoded)); |
| manifestCoveredTargets.add(Uri(path: Uri.encodeFull(decoded)).path); |
| } |
| |
| // Pass 2a: Rewrite FontManifest.json and hash it on disk. |
| // FontManifest.json keeps the `%20`-encoded `posixNewPath` because `loadAssetFonts` |
| // in `web_ui` calls `assetManager.loadAsset` directly without `PlatformAssetBundle`. |
| String? hashedFontManifestJson; |
| final File fontManifest = assetsDir.childFile('FontManifest.json'); |
| if (fontManifest.existsSync()) { |
| final Object? decodedJson = json.decode(fontManifest.readAsStringSync()); |
| if (decodedJson is List<dynamic>) { |
| for (final Object? font in decodedJson) { |
| if (font is Map<String, dynamic>) { |
| final Object? fonts = font['fonts']; |
| if (fonts is List<dynamic>) { |
| for (final Object? fontAsset in fonts) { |
| if (fontAsset is Map<String, dynamic>) { |
| final Object? asset = fontAsset['asset']; |
| if (asset is String && renamedAssets.containsKey(asset)) { |
| fontAsset['asset'] = renamedAssets[asset]; |
| } |
| } |
| } |
| } |
| } |
| } |
| fontManifest.writeAsStringSync(json.encode(decodedJson)); |
| } |
| hashedFontManifestJson = hashAndRenameFile(fontManifest, 'FontManifest.json'); |
| } |
| |
| // Pass 2b: Rewrite legacy AssetManifest.json if present and hash it on disk. |
| final File assetManifestJson = assetsDir.childFile('AssetManifest.json'); |
| if (assetManifestJson.existsSync()) { |
| final Object? decodedJson = json.decode(assetManifestJson.readAsStringSync()); |
| if (decodedJson is Map<String, dynamic>) { |
| final newManifest = <String, dynamic>{}; |
| for (final MapEntry<String, dynamic> entry in decodedJson.entries) { |
| recordManifestTarget(entry.key); |
| final Object? variants = entry.value; |
| if (variants is! List<dynamic>) { |
| newManifest[entry.key] = variants; |
| continue; |
| } |
| final newVariants = <String>[]; |
| for (final Object? variant in variants) { |
| if (variant is String) { |
| recordManifestTarget(variant); |
| newVariants.add(decodedRenamedAssets[variant] ?? variant); |
| } else if (variant != null) { |
| newVariants.add(variant.toString()); |
| } |
| } |
| newManifest[entry.key] = newVariants; |
| } |
| assetManifestJson.writeAsStringSync(json.encode(newManifest)); |
| } |
| hashAndRenameFile(assetManifestJson, 'AssetManifest.json'); |
| } |
| |
| // Pass 2c: Rewrite AssetManifest.bin and AssetManifest.bin.json and hash both on disk. |
| // Do NOT insert non-manifest SDK files (`NOTICES`, framework shaders, `FontManifest.json`, |
| // `AssetManifest.bin`, etc.) into `AssetManifest.bin` or `AssetManifest.bin.json` so that |
| // `AssetManifest.loadFromAssetBundle(rootBundle).listAssets()` returns the exact same keys |
| // as a build without `--web-content-hash`. |
| String? hashedAssetManifestBinJson; |
| final File assetManifestBin = assetsDir.childFile('AssetManifest.bin'); |
| final File assetManifestBinJson = assetsDir.childFile('AssetManifest.bin.json'); |
| if (assetManifestBin.existsSync()) { |
| final Uint8List rawBytes = assetManifestBin.readAsBytesSync(); |
| final message = ByteData.sublistView(rawBytes); |
| final Object? decoded = const StandardMessageCodec().decodeMessage(message); |
| if (decoded is Map<Object?, Object?>) { |
| final newManifest = <String, dynamic>{}; |
| for (final MapEntry<Object?, Object?> entry in decoded.entries) { |
| final key = entry.key.toString(); |
| recordManifestTarget(key); |
| final Object? variantsVal = entry.value; |
| if (variantsVal is! List<Object?>) { |
| newManifest[key] = variantsVal; |
| continue; |
| } |
| final newVariants = <dynamic>[]; |
| for (final Object? variantObj in variantsVal) { |
| if (variantObj is! Map<Object?, Object?>) { |
| newVariants.add(variantObj); |
| continue; |
| } |
| final newVariantMap = <String, dynamic>{}; |
| for (final MapEntry<Object?, Object?> vEntry in variantObj.entries) { |
| final vKey = vEntry.key.toString(); |
| if (vKey == 'asset') { |
| final vValue = vEntry.value.toString(); |
| recordManifestTarget(vValue); |
| newVariantMap[vKey] = decodedRenamedAssets[vValue] ?? vValue; |
| } else { |
| newVariantMap[vKey] = vEntry.value; |
| } |
| } |
| newVariants.add(newVariantMap); |
| } |
| newManifest[key] = newVariants; |
| } |
| final ByteData encoded = const StandardMessageCodec().encodeMessage(newManifest)!; |
| final encodedBytes = Uint8List.sublistView(encoded); |
| assetManifestBin.writeAsBytesSync(encodedBytes); |
| hashAndRenameFile(assetManifestBin, 'AssetManifest.bin'); |
| |
| // Update AssetManifest.bin.json with the same manifest entries and hash it on disk. |
| if (assetManifestBinJson.existsSync()) { |
| assetManifestBinJson.writeAsStringSync(json.encode(base64.encode(encodedBytes))); |
| hashedAssetManifestBinJson = hashAndRenameFile( |
| assetManifestBinJson, |
| 'AssetManifest.bin.json', |
| ); |
| } |
| } |
| } |
| |
| // Collect non-manifest files in `build/web/assets/` (such as `NOTICES`, |
| // framework shaders not listed in `pubspec.yaml`, `FontManifest.json`, |
| // `AssetManifest.bin`, and `AssetManifest.json`) into `extraAssets` for |
| // `_flutter.buildConfig`. |
| final extraAssets = <String, String>{}; |
| for (final MapEntry<String, String> entry in rawRenamedAssets.entries) { |
| if (entry.key == 'AssetManifest.bin.json') { |
| continue; |
| } |
| if (!manifestCoveredTargets.contains(entry.key) && |
| !manifestCoveredTargets.contains(decodeSafe(entry.key))) { |
| extraAssets[decodeSafe(entry.key)] = entry.value; |
| } |
| } |
| |
| return WebAssetHashResult( |
| renamedFiles: renamedFileMap, |
| assetManifestBinJson: hashedAssetManifestBinJson, |
| fontManifestJson: hashedFontManifestJson, |
| extraAssets: extraAssets, |
| ); |
| } |
| |
| final RegExp _htmlCommentRegex = RegExp(r'<!--[\s\S]*?-->'); |
| |
| final RegExp _jsCommentOrStringRegex = RegExp( |
| r'/\*[\s\S]*?\*/|(?<!:)//[^\r\n]*|"(?:\\.|[^"\\\r\n])*"|' |
| "'" |
| r'(?:\\.|[^' |
| "'" |
| r'\\\r\n])*' |
| "'" |
| r'|`(?:\\.|[^`\\])*`', |
| ); |
| |
| /// Strips HTML (`<!-- ... -->`) and JavaScript (`/* ... */`, `// ...`) comments |
| /// from [content] while preserving single-quoted, double-quoted, and template |
| /// string literals. |
| String stripHtmlAndJsComments(String content) { |
| final String withoutHtmlComments = content.replaceAll(_htmlCommentRegex, ''); |
| return withoutHtmlComments.replaceAllMapped(_jsCommentOrStringRegex, (Match match) { |
| final String token = match.group(0)!; |
| if (token.startsWith('/*') || token.startsWith('//')) { |
| return ''; |
| } |
| return token; |
| }); |
| } |
| |
| final RegExp _buildConfigTokenOrCommentRegex = RegExp( |
| r'<!--[\s\S]*?-->|/\*[\s\S]*?\*/|(?<!:)//[^\r\n]*|"(?:\\.|[^"\\\r\n])*"|' |
| "'" |
| r'(?:\\.|[^' |
| "'" |
| r'\\\r\n])*' |
| "'" |
| r'|`(?:\\.|[^`\\])*`|_flutter\.buildConfig\s*=\s*\{', |
| ); |
| |
| int? _findMatchingClosingBrace(String text, int openBraceIndex) { |
| var depth = 0; |
| int? stringQuote; |
| var escaped = false; |
| for (var i = openBraceIndex; i < text.length; i++) { |
| final int ch = text.codeUnitAt(i); |
| if (stringQuote != null) { |
| if (escaped) { |
| escaped = false; |
| } else if (ch == 0x5C /* \ */ ) { |
| escaped = true; |
| } else if (ch == stringQuote) { |
| stringQuote = null; |
| } |
| continue; |
| } |
| if (ch == 0x22 /* " */ || ch == 0x27 /* ' */ || ch == 0x60 /* ` */ ) { |
| stringQuote = ch; |
| } else if (ch == 0x7B /* { */ ) { |
| depth++; |
| } else if (ch == 0x7D /* } */ ) { |
| depth--; |
| if (depth == 0) { |
| return i; |
| } |
| } |
| } |
| return null; |
| } |
| |
| String _updateBuildConfigInContent( |
| String content, |
| WebAssetHashResult hashResult, { |
| required String filePath, |
| }) { |
| final List<Match> matches = _buildConfigTokenOrCommentRegex |
| .allMatches(content) |
| .where((Match m) => m.group(0)!.startsWith('_flutter.buildConfig')) |
| .toList(); |
| if (matches.isEmpty) { |
| throwToolExit( |
| 'Failed to inject content-hashed asset manifest into $filePath: ' |
| '"_flutter.buildConfig" is not a JSON object assignment. ' |
| 'Ensure "{{flutter_build_config}}" is used.', |
| ); |
| } |
| final buffer = StringBuffer(); |
| var cursor = 0; |
| for (final match in matches) { |
| if (match.start < cursor) { |
| continue; |
| } |
| final int openBraceIndex = match.end - 1; |
| final int? closeBraceIndex = _findMatchingClosingBrace(content, openBraceIndex); |
| if (closeBraceIndex == null) { |
| throwToolExit( |
| 'Failed to inject content-hashed asset manifest into $filePath: ' |
| 'unterminated "_flutter.buildConfig" object.', |
| ); |
| } |
| int endIndex = closeBraceIndex + 1; |
| if (endIndex < content.length && content.codeUnitAt(endIndex) == 0x3B /* ; */ ) { |
| endIndex++; |
| } |
| final String jsonPart = content.substring(openBraceIndex, closeBraceIndex + 1); |
| try { |
| final Object? decoded = json.decode(jsonPart); |
| if (decoded is! Map<String, Object?>) { |
| throwToolExit( |
| 'Failed to inject content-hashed asset manifest into $filePath: ' |
| '"_flutter.buildConfig" is not a JSON object.', |
| ); |
| } |
| final updatedMap = <String, Object?>{ |
| ...decoded, |
| if (hashResult.assetManifestBinJson != null) |
| 'assetManifest': hashResult.assetManifestBinJson, |
| if (hashResult.fontManifestJson != null) 'fontManifest': hashResult.fontManifestJson, |
| if (hashResult.extraAssets.isNotEmpty) 'extraAssets': hashResult.extraAssets, |
| }; |
| buffer.write(content.substring(cursor, match.start)); |
| buffer.write('_flutter.buildConfig = ${json.encode(updatedMap)};'); |
| cursor = endIndex; |
| } on FormatException catch (e) { |
| throwToolExit( |
| 'Failed to inject content-hashed asset manifest into $filePath: ' |
| '"_flutter.buildConfig" is not valid JSON ($e). ' |
| 'Ensure "{{flutter_build_config}}" is used.', |
| ); |
| } |
| } |
| if (cursor == 0) { |
| return content; |
| } |
| buffer.write(content.substring(cursor)); |
| return buffer.toString(); |
| } |
| |
| /// Injects `"assetManifest"`, `"fontManifest"`, and `"extraAssets"` into |
| /// `_flutter.buildConfig` inside `flutter_bootstrap.js` and `index.html` in |
| /// [outputDir]. |
| void injectManifestBuildConfig(Directory outputDir, WebAssetHashResult hashResult) { |
| if (!outputDir.existsSync()) { |
| return; |
| } |
| if (hashResult.assetManifestBinJson == null && |
| hashResult.fontManifestJson == null && |
| hashResult.extraAssets.isEmpty) { |
| return; |
| } |
| for (final File file in outputDir.listSync(recursive: true).whereType<File>()) { |
| final String basename = file.fileSystem.path.basename(file.path); |
| if (basename != 'flutter_bootstrap.js' && basename != 'index.html') { |
| continue; |
| } |
| final String content = file.readAsStringSync(); |
| if (!stripHtmlAndJsComments(content).contains('_flutter.buildConfig')) { |
| continue; |
| } |
| final String updated = _updateBuildConfigInContent(content, hashResult, filePath: file.path); |
| if (updated != content) { |
| file.writeAsStringSync(updated); |
| } |
| } |
| if (hashResult.assetManifestBinJson != null) { |
| final File bootstrapFile = outputDir.childFile('flutter_bootstrap.js'); |
| if (!bootstrapFile.existsSync() || |
| !bootstrapFile.readAsStringSync().contains('"assetManifest"')) { |
| throwToolExit( |
| 'Failed to inject content-hashed "assetManifest" into ${bootstrapFile.path}. ' |
| 'Ensure web/flutter_bootstrap.js contains the "{{flutter_build_config}}" placeholder.', |
| ); |
| } |
| } |
| } |
| |
| /// The filename of the precache manifest generated for web content hashing. |
| const String kPrecacheManifestFile = 'precache_manifest.json'; |
| |
| bool _shouldExcludeFromPrecacheManifest({ |
| required String posixUrl, |
| required String basename, |
| required List<String> segments, |
| required bool useLocalCanvasKit, |
| }) { |
| if (basename.startsWith('.') || |
| posixUrl == kPrecacheManifestFile || |
| posixUrl == 'flutter_service_worker.js') { |
| return true; |
| } |
| if (basename.endsWith('.map') || |
| basename.endsWith('.symbols') || |
| basename.endsWith('.info.json')) { |
| return true; |
| } |
| if (!useLocalCanvasKit && segments.firstOrNull == 'canvaskit') { |
| return true; |
| } |
| return false; |
| } |
| |
| /// Writes `precache_manifest.json` in [outputDir] when [enabled] is true, or |
| /// removes any stale manifest file when [enabled] is false. |
| File? updatePrecacheManifest( |
| Directory outputDir, { |
| required bool enabled, |
| required bool useLocalCanvasKit, |
| }) { |
| final File manifestFile = outputDir.childFile(kPrecacheManifestFile); |
| if (!enabled) { |
| if (manifestFile.existsSync()) { |
| manifestFile.deleteSync(); |
| } |
| return null; |
| } |
| |
| if (!outputDir.existsSync()) { |
| return null; |
| } |
| |
| final FileSystem fileSystem = outputDir.fileSystem; |
| final List<File> files = outputDir.listSync(recursive: true).whereType<File>().toList(); |
| |
| final entries = <Map<String, Object>>[]; |
| for (final file in files) { |
| final String relativePath = fileSystem.path.relative(file.path, from: outputDir.path); |
| final List<String> segments = fileSystem.path.split(relativePath); |
| final String posixUrl = p.posix.joinAll(segments); |
| final String basename = segments.last; |
| |
| if (_shouldExcludeFromPrecacheManifest( |
| posixUrl: posixUrl, |
| basename: basename, |
| segments: segments, |
| useLocalCanvasKit: useLocalCanvasKit, |
| )) { |
| continue; |
| } |
| |
| final Uint8List bytes = file.readAsBytesSync(); |
| final String shortHash = crypto.sha256.convert(bytes).toString().substring(0, 8); |
| final bool urlHashed = basename.contains('.$shortHash.') || basename.endsWith('.$shortHash'); |
| |
| entries.add(<String, Object>{ |
| 'url': posixUrl, |
| 'hash': shortHash, |
| 'size': bytes.length, |
| 'urlHashed': urlHashed, |
| }); |
| } |
| |
| entries.sort( |
| (Map<String, Object> a, Map<String, Object> b) => |
| (a['url']! as String).compareTo(b['url']! as String), |
| ); |
| |
| manifestFile.writeAsStringSync( |
| const JsonEncoder.withIndent(' ').convert(<String, Object>{'version': 1, 'entries': entries}), |
| ); |
| return manifestFile; |
| } |