Skip to content

Commit c7c9d8e

Browse files
authored
[web] Encode AssetManifest.bin as JSON and use that on the web. (flutter#131382)
This PR modifies the web build slightly to create an `AssetManifest.json`, that is a JSON(base64)-encoded version of the `AssetManifest.bin` file. _(This should enable all browsers to download the file without any interference, and all servers to serve it with the correct headers.)_ It also modifies Flutter's `AssetManifest` class so it loads and uses said file `if (kIsWeb)`. ### Issues * Fixes flutter#124883 ### Tests * Unit tests added. * Some tests that run on the Web needed to be informed of the new filename, but their behavior didn't have to change (binary contents are the same across all platforms). * I've deployed a test app, so users affected by the BIN issue may take a look at the PR in action: * https://dit-tests.web.app
1 parent ba2dde4 commit c7c9d8e

File tree

7 files changed

+205
-27
lines changed

7 files changed

+205
-27
lines changed

packages/flutter/lib/src/services/asset_bundle.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ abstract class AssetBundle {
8888
Future<String> loadString(String key, { bool cache = true }) async {
8989
final ByteData data = await load(key);
9090
// 50 KB of data should take 2-3 ms to parse on a Moto G4, and about 400 μs
91-
// on a Pixel 4.
92-
if (data.lengthInBytes < 50 * 1024) {
91+
// on a Pixel 4. On the web we can't bail to isolates, though...
92+
if (data.lengthInBytes < 50 * 1024 || kIsWeb) {
9393
return utf8.decode(Uint8List.sublistView(data));
9494
}
9595
// For strings larger than 50 KB, run the computation in an isolate to

packages/flutter/lib/src/services/asset_manifest.dart

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,45 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:convert';
6+
57
import 'package:flutter/foundation.dart';
68

79
import 'asset_bundle.dart';
810
import 'message_codecs.dart';
911

1012
// We use .bin as the extension since it is well-known to represent
11-
// data in some arbitrary binary format. Using a well-known extension here
12-
// is important for web, because some web servers will not serve files with
13-
// unrecognized file extensions by default.
14-
// See https://github.com/flutter/flutter/issues/128456.
13+
// data in some arbitrary binary format.
1514
const String _kAssetManifestFilename = 'AssetManifest.bin';
1615

16+
// We use the same bin file for the web, but re-encoded as JSON(base64(bytes))
17+
// so it can be downloaded by even the dumbest of browsers.
18+
// See https://github.com/flutter/flutter/issues/128456
19+
const String _kAssetManifestWebFilename = 'AssetManifest.bin.json';
20+
1721
/// Contains details about available assets and their variants.
1822
/// See [Resolution-aware image assets](https://docs.flutter.dev/ui/assets-and-images#resolution-aware)
1923
/// to learn about asset variants and how to declare them.
2024
abstract class AssetManifest {
2125
/// Loads asset manifest data from an [AssetBundle] object and creates an
2226
/// [AssetManifest] object from that data.
2327
static Future<AssetManifest> loadFromAssetBundle(AssetBundle bundle) {
28+
// The AssetManifest file contains binary data.
29+
//
30+
// On the web, the build process wraps this binary data in json+base64 so
31+
// it can be transmitted over the network without special configuration
32+
// (see #131382).
33+
if (kIsWeb) {
34+
// On the web, the AssetManifest is downloaded as a String, then
35+
// json+base64-decoded to get to the binary data.
36+
return bundle.loadStructuredData(_kAssetManifestWebFilename, (String jsonData) async {
37+
// Decode the manifest JSON file to the underlying BIN, and convert to ByteData.
38+
final ByteData message = ByteData.sublistView(base64.decode(json.decode(jsonData) as String));
39+
// Now we can keep operating as usual.
40+
return _AssetManifestBin.fromStandardMessageCodecMessage(message);
41+
});
42+
}
43+
// On every other platform, the binary file contents are used directly.
2444
return bundle.loadStructuredBinaryData(_kAssetManifestFilename, _AssetManifestBin.fromStandardMessageCodecMessage);
2545
}
2646

packages/flutter/test/painting/image_resolution_test.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:convert';
56
import 'dart:ui' as ui;
67

78
import 'package:flutter/foundation.dart';
@@ -22,6 +23,22 @@ class TestAssetBundle extends CachingAssetBundle {
2223
return const StandardMessageCodec().encodeMessage(_assetBundleMap)!;
2324
}
2425

26+
if (key == 'AssetManifest.bin.json') {
27+
// Encode the manifest data that will be used by the app
28+
final ByteData data = const StandardMessageCodec().encodeMessage(_assetBundleMap)!;
29+
// Simulate the behavior of NetworkAssetBundle.load here, for web tests
30+
return ByteData.sublistView(
31+
utf8.encode(
32+
json.encode(
33+
base64.encode(
34+
// Encode only the actual bytes of the buffer, and no more...
35+
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes)
36+
)
37+
)
38+
)
39+
);
40+
}
41+
2542
loadCallCount[key] = loadCallCount[key] ?? 0 + 1;
2643
if (key == 'one') {
2744
return ByteData(1)

packages/flutter/test/services/asset_bundle_test.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ class TestAssetBundle extends CachingAssetBundle {
2525
.encodeMessage(<String, Object>{'one': <Object>[]})!;
2626
}
2727

28+
if (key == 'AssetManifest.bin.json') {
29+
// Encode the manifest data that will be used by the app
30+
final ByteData data = const StandardMessageCodec().encodeMessage(<String, Object> {'one': <Object>[]})!;
31+
// Simulate the behavior of NetworkAssetBundle.load here, for web tests
32+
return ByteData.sublistView(
33+
utf8.encode(
34+
json.encode(
35+
base64.encode(
36+
// Encode only the actual bytes of the buffer, and no more...
37+
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes)
38+
)
39+
)
40+
)
41+
);
42+
}
43+
2844
if (key == 'counter') {
2945
return ByteData.sublistView(utf8.encode(loadCallCount[key]!.toString()));
3046
}

packages/flutter/test/services/asset_manifest_test.dart

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,52 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:convert';
6+
57
import 'package:flutter/services.dart';
68
import 'package:flutter_test/flutter_test.dart';
79

810
class TestAssetBundle extends AssetBundle {
11+
static const Map<String, List<Object>> _binManifestData = <String, List<Object>>{
12+
'assets/foo.png': <Object>[
13+
<String, Object>{
14+
'asset': 'assets/foo.png',
15+
},
16+
<String, Object>{
17+
'asset': 'assets/2x/foo.png',
18+
'dpr': 2.0
19+
},
20+
],
21+
'assets/bar.png': <Object>[
22+
<String, Object>{
23+
'asset': 'assets/bar.png',
24+
},
25+
],
26+
};
27+
928
@override
1029
Future<ByteData> load(String key) async {
1130
if (key == 'AssetManifest.bin') {
12-
final Map<String, List<Object>> binManifestData = <String, List<Object>>{
13-
'assets/foo.png': <Object>[
14-
<String, Object>{
15-
'asset': 'assets/foo.png',
16-
},
17-
<String, Object>{
18-
'asset': 'assets/2x/foo.png',
19-
'dpr': 2.0
20-
},
21-
],
22-
'assets/bar.png': <Object>[
23-
<String, Object>{
24-
'asset': 'assets/bar.png',
25-
},
26-
],
27-
};
28-
29-
final ByteData data = const StandardMessageCodec().encodeMessage(binManifestData)!;
31+
final ByteData data = const StandardMessageCodec().encodeMessage(_binManifestData)!;
3032
return data;
3133
}
3234

35+
if (key == 'AssetManifest.bin.json') {
36+
// Encode the manifest data that will be used by the app
37+
final ByteData data = const StandardMessageCodec().encodeMessage(_binManifestData)!;
38+
// Simulate the behavior of NetworkAssetBundle.load here, for web tests
39+
return ByteData.sublistView(
40+
utf8.encode(
41+
json.encode(
42+
base64.encode(
43+
// Encode only the actual bytes of the buffer, and no more...
44+
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes)
45+
)
46+
)
47+
)
48+
);
49+
}
50+
3351
throw ArgumentError('Unexpected key');
3452
}
3553

packages/flutter_tools/lib/src/asset.dart

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ class ManifestAssetBundle implements AssetBundle {
168168
// We assume the main asset is designed for a device pixel ratio of 1.0.
169169
static const String _kAssetManifestJsonFilename = 'AssetManifest.json';
170170
static const String _kAssetManifestBinFilename = 'AssetManifest.bin';
171+
static const String _kAssetManifestBinJsonFilename = 'AssetManifest.bin.json';
171172

172173
static const String _kNoticeFile = 'NOTICES';
173174
// Comically, this can't be name with the more common .gz file extension
@@ -233,15 +234,18 @@ class ManifestAssetBundle implements AssetBundle {
233234
// device.
234235
_lastBuildTimestamp = DateTime.now();
235236
if (flutterManifest.isEmpty) {
236-
entries[_kAssetManifestJsonFilename] = DevFSStringContent('{}');
237-
entryKinds[_kAssetManifestJsonFilename] = AssetKind.regular;
238237
entries[_kAssetManifestJsonFilename] = DevFSStringContent('{}');
239238
entryKinds[_kAssetManifestJsonFilename] = AssetKind.regular;
240239
final ByteData emptyAssetManifest =
241240
const StandardMessageCodec().encodeMessage(<dynamic, dynamic>{})!;
242241
entries[_kAssetManifestBinFilename] =
243242
DevFSByteContent(emptyAssetManifest.buffer.asUint8List(0, emptyAssetManifest.lengthInBytes));
244243
entryKinds[_kAssetManifestBinFilename] = AssetKind.regular;
244+
// Create .bin.json on web builds.
245+
if (targetPlatform == TargetPlatform.web_javascript) {
246+
entries[_kAssetManifestBinJsonFilename] = DevFSStringContent('""');
247+
entryKinds[_kAssetManifestBinJsonFilename] = AssetKind.regular;
248+
}
245249
return 0;
246250
}
247251

@@ -437,8 +441,8 @@ class ManifestAssetBundle implements AssetBundle {
437441

438442
final Map<String, List<String>> assetManifest =
439443
_createAssetManifest(assetVariants, deferredComponentsAssetVariants);
440-
final DevFSStringContent assetManifestJson = DevFSStringContent(json.encode(assetManifest));
441444
final DevFSByteContent assetManifestBinary = _createAssetManifestBinary(assetManifest);
445+
final DevFSStringContent assetManifestJson = DevFSStringContent(json.encode(assetManifest));
442446
final DevFSStringContent fontManifest = DevFSStringContent(json.encode(fonts));
443447
final LicenseResult licenseResult = _licenseCollector.obtainLicenses(packageConfig, additionalLicenseFiles);
444448
if (licenseResult.errorMessages.isNotEmpty) {
@@ -464,6 +468,13 @@ class ManifestAssetBundle implements AssetBundle {
464468

465469
_setIfChanged(_kAssetManifestJsonFilename, assetManifestJson, AssetKind.regular);
466470
_setIfChanged(_kAssetManifestBinFilename, assetManifestBinary, AssetKind.regular);
471+
// Create .bin.json on web builds.
472+
if (targetPlatform == TargetPlatform.web_javascript) {
473+
final DevFSStringContent assetManifestBinaryJson = DevFSStringContent(json.encode(
474+
base64.encode(assetManifestBinary.bytes)
475+
));
476+
_setIfChanged(_kAssetManifestBinJsonFilename, assetManifestBinaryJson, AssetKind.regular);
477+
}
467478
_setIfChanged(kFontManifestJson, fontManifest, AssetKind.regular);
468479
_setLicenseIfChanged(licenseResult.combinedLicenses, targetPlatform);
469480
return 0;

packages/flutter_tools/test/general.shard/asset_bundle_test.dart

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,102 @@ flutter:
325325
});
326326
});
327327

328+
group('AssetBundle.build (web builds)', () {
329+
late FileSystem testFileSystem;
330+
331+
setUp(() async {
332+
testFileSystem = MemoryFileSystem(
333+
style: globals.platform.isWindows
334+
? FileSystemStyle.windows
335+
: FileSystemStyle.posix,
336+
);
337+
testFileSystem.currentDirectory = testFileSystem.systemTempDirectory.createTempSync('flutter_asset_bundle_test.');
338+
});
339+
340+
testUsingContext('empty pubspec', () async {
341+
globals.fs.file('pubspec.yaml')
342+
..createSync()
343+
..writeAsStringSync('');
344+
345+
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
346+
await bundle.build(packagesPath: '.packages', targetPlatform: TargetPlatform.web_javascript);
347+
348+
expect(bundle.entries.keys,
349+
unorderedEquals(<String>[
350+
'AssetManifest.json',
351+
'AssetManifest.bin',
352+
'AssetManifest.bin.json',
353+
])
354+
);
355+
expect(
356+
utf8.decode(await bundle.entries['AssetManifest.json']!.contentsAsBytes()),
357+
'{}',
358+
);
359+
expect(
360+
utf8.decode(await bundle.entries['AssetManifest.bin.json']!.contentsAsBytes()),
361+
'""',
362+
);
363+
}, overrides: <Type, Generator>{
364+
FileSystem: () => testFileSystem,
365+
ProcessManager: () => FakeProcessManager.any(),
366+
});
367+
368+
testUsingContext('pubspec contains an asset', () async {
369+
globals.fs.file('.packages').createSync();
370+
globals.fs.file('pubspec.yaml').writeAsStringSync(r'''
371+
name: test
372+
dependencies:
373+
flutter:
374+
sdk: flutter
375+
flutter:
376+
assets:
377+
- assets/bar/lizard.png
378+
''');
379+
globals.fs.file(
380+
globals.fs.path.joinAll(<String>['assets', 'bar', 'lizard.png'])
381+
).createSync(recursive: true);
382+
383+
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
384+
await bundle.build(packagesPath: '.packages', targetPlatform: TargetPlatform.web_javascript);
385+
386+
expect(bundle.entries.keys,
387+
unorderedEquals(<String>[
388+
'AssetManifest.json',
389+
'AssetManifest.bin',
390+
'AssetManifest.bin.json',
391+
'FontManifest.json',
392+
'NOTICES', // not .Z
393+
'assets/bar/lizard.png',
394+
])
395+
);
396+
final Map<Object?, Object?> manifestJson = json.decode(
397+
utf8.decode(
398+
await bundle.entries['AssetManifest.json']!.contentsAsBytes()
399+
)
400+
) as Map<Object?, Object?>;
401+
expect(manifestJson, isNotEmpty);
402+
expect(manifestJson['assets/bar/lizard.png'], isNotNull);
403+
404+
final Uint8List manifestBinJsonBytes = base64.decode(
405+
json.decode(
406+
utf8.decode(
407+
await bundle.entries['AssetManifest.bin.json']!.contentsAsBytes()
408+
)
409+
) as String
410+
);
411+
412+
final Uint8List manifestBinBytes = Uint8List.fromList(
413+
await bundle.entries['AssetManifest.bin']!.contentsAsBytes()
414+
);
415+
416+
expect(manifestBinJsonBytes, equals(manifestBinBytes),
417+
reason: 'JSON-encoded binary content should be identical to BIN file.');
418+
}, overrides: <Type, Generator>{
419+
FileSystem: () => testFileSystem,
420+
ProcessManager: () => FakeProcessManager.any(),
421+
});
422+
});
423+
328424
testUsingContext('Failed directory delete shows message', () async {
329425
final FileExceptionHandler handler = FileExceptionHandler();
330426
final FileSystem fileSystem = MemoryFileSystem.test(opHandle: handler.opHandle);

0 commit comments

Comments
 (0)