Skip to content

Commit 48eee14

Browse files
authored
Support --web-header option for flutter run (#136297)
Adds support for a new --web-header option to flutter run. Creates a workaround for flutter/flutter#127902 This PR allows adding additional headers for the flutter run web server. This is useful to add headers like Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy without the use of a proxy server. These headers are required enable advanced web features. This approach provides flexibility to the developer to make use of the feature as they see fit and is backward-compatible. One tradeoff is that it increases the surface area to support for future changes to the flutter web server. flutter/flutter#127902 is not fully addressed by this change. The solution for that task will be more opinionated. This PR creates a general-purpose workaround for anyone who needs a solution sooner while the bigger solution is developed.
1 parent b136ddc commit 48eee14

File tree

7 files changed

+199
-0
lines changed

7 files changed

+199
-0
lines changed

packages/flutter_tools/lib/src/commands/run.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
239239
final List<String> webBrowserFlags = featureFlags.isWebEnabled
240240
? stringsArg(FlutterOptions.kWebBrowserFlag)
241241
: const <String>[];
242+
243+
final Map<String, String> webHeaders = featureFlags.isWebEnabled
244+
? extractWebHeaders()
245+
: const <String, String>{};
246+
242247
if (buildInfo.mode.isRelease) {
243248
return DebuggingOptions.disabled(
244249
buildInfo,
@@ -252,6 +257,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
252257
webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'),
253258
webBrowserDebugPort: webBrowserDebugPort,
254259
webBrowserFlags: webBrowserFlags,
260+
webHeaders: webHeaders,
255261
enableImpeller: enableImpeller,
256262
enableVulkanValidation: enableVulkanValidation,
257263
impellerForceGL: impellerForceGL,
@@ -298,6 +304,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
298304
webBrowserFlags: webBrowserFlags,
299305
webEnableExpressionEvaluation: featureFlags.isWebEnabled && boolArg('web-enable-expression-evaluation'),
300306
webLaunchUrl: featureFlags.isWebEnabled ? stringArg('web-launch-url') : null,
307+
webHeaders: webHeaders,
301308
vmserviceOutFile: stringArg('vmservice-out-file'),
302309
fastStart: argParser.options.containsKey('fast-start')
303310
&& boolArg('fast-start')

packages/flutter_tools/lib/src/device.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,7 @@ class DebuggingOptions {
959959
this.webBrowserDebugPort,
960960
this.webBrowserFlags = const <String>[],
961961
this.webEnableExpressionEvaluation = false,
962+
this.webHeaders = const <String, String>{},
962963
this.webLaunchUrl,
963964
this.vmserviceOutFile,
964965
this.fastStart = false,
@@ -986,6 +987,7 @@ class DebuggingOptions {
986987
this.webBrowserDebugPort,
987988
this.webBrowserFlags = const <String>[],
988989
this.webLaunchUrl,
990+
this.webHeaders = const <String, String>{},
989991
this.cacheSkSL = false,
990992
this.traceAllowlist,
991993
this.enableImpeller = ImpellerStatus.platformDefault,
@@ -1061,6 +1063,7 @@ class DebuggingOptions {
10611063
required this.webBrowserDebugPort,
10621064
required this.webBrowserFlags,
10631065
required this.webEnableExpressionEvaluation,
1066+
required this.webHeaders,
10641067
required this.webLaunchUrl,
10651068
required this.vmserviceOutFile,
10661069
required this.fastStart,
@@ -1141,6 +1144,9 @@ class DebuggingOptions {
11411144
/// Allow developers to customize the browser's launch URL
11421145
final String? webLaunchUrl;
11431146

1147+
/// Allow developers to add custom headers to web server
1148+
final Map<String, String> webHeaders;
1149+
11441150
/// A file where the VM Service URL should be written after the application is started.
11451151
final String? vmserviceOutFile;
11461152
final bool fastStart;
@@ -1246,6 +1252,7 @@ class DebuggingOptions {
12461252
'webBrowserFlags': webBrowserFlags,
12471253
'webEnableExpressionEvaluation': webEnableExpressionEvaluation,
12481254
'webLaunchUrl': webLaunchUrl,
1255+
'webHeaders': webHeaders,
12491256
'vmserviceOutFile': vmserviceOutFile,
12501257
'fastStart': fastStart,
12511258
'nullAssertions': nullAssertions,
@@ -1297,6 +1304,7 @@ class DebuggingOptions {
12971304
webBrowserDebugPort: json['webBrowserDebugPort'] as int?,
12981305
webBrowserFlags: (json['webBrowserFlags']! as List<dynamic>).cast<String>(),
12991306
webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool,
1307+
webHeaders: (json['webHeaders']! as Map<dynamic, dynamic>).cast<String, String>(),
13001308
webLaunchUrl: json['webLaunchUrl'] as String?,
13011309
vmserviceOutFile: json['vmserviceOutFile'] as String?,
13021310
fastStart: json['fastStart']! as bool,

packages/flutter_tools/lib/src/isolated/devfs_web.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ class WebAssetServer implements AssetReader {
189189
bool enableDds,
190190
Uri entrypoint,
191191
ExpressionCompiler? expressionCompiler,
192+
Map<String, String> extraHeaders,
192193
NullSafetyMode nullSafetyMode, {
193194
bool testMode = false,
194195
DwdsLauncher dwdsLauncher = Dwds.start,
@@ -217,6 +218,10 @@ class WebAssetServer implements AssetReader {
217218
// Allow rendering in a iframe.
218219
httpServer!.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN');
219220

221+
for (final MapEntry<String, String> header in extraHeaders.entries) {
222+
httpServer.defaultResponseHeaders.add(header.key, header.value);
223+
}
224+
220225
final PackageConfig packageConfig = buildInfo.packageConfig;
221226
final Map<String, String> digests = <String, String>{};
222227
final Map<String, String> modules = <String, String>{};
@@ -653,6 +658,7 @@ class WebDevFS implements DevFS {
653658
required this.enableDds,
654659
required this.entrypoint,
655660
required this.expressionCompiler,
661+
required this.extraHeaders,
656662
required this.chromiumLauncher,
657663
required this.nullAssertions,
658664
required this.nativeNullAssertions,
@@ -670,6 +676,7 @@ class WebDevFS implements DevFS {
670676
final BuildInfo buildInfo;
671677
final bool enableDwds;
672678
final bool enableDds;
679+
final Map<String, String> extraHeaders;
673680
final bool testMode;
674681
final ExpressionCompiler? expressionCompiler;
675682
final ChromiumLauncher? chromiumLauncher;
@@ -772,6 +779,7 @@ class WebDevFS implements DevFS {
772779
enableDds,
773780
entrypoint,
774781
expressionCompiler,
782+
extraHeaders,
775783
nullSafetyMode,
776784
testMode: testMode,
777785
);

packages/flutter_tools/lib/src/isolated/resident_web_runner.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive).
297297
enableDds: debuggingOptions.enableDds,
298298
entrypoint: _fileSystem.file(target).uri,
299299
expressionCompiler: expressionCompiler,
300+
extraHeaders: debuggingOptions.webHeaders,
300301
chromiumLauncher: _chromiumLauncher,
301302
nullAssertions: debuggingOptions.nullAssertions,
302303
nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode,

packages/flutter_tools/lib/src/runner/flutter_command.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ abstract class DotEnvRegex {
5959
static final RegExp unquotedValue = RegExp(r'^([^#\n\s]*)\s*(?:\s*#\s*(.*))?$');
6060
}
6161

62+
abstract class _HttpRegex {
63+
// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
64+
static const String _vchar = r'\x21-\x7E';
65+
static const String _spaceOrTab = r'\x20\x09';
66+
static const String _nonDelimiterVchar = r'\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7A\x7C\x7E';
67+
68+
// --web-header is provided as key=value for consistency with --dart-define
69+
static final RegExp httpHeader = RegExp('^([$_nonDelimiterVchar]+)' r'\s*=\s*' '([$_vchar$_spaceOrTab]+)' r'$');
70+
}
71+
6272
enum ExitStatus {
6373
success,
6474
warning,
@@ -218,6 +228,14 @@ abstract class FlutterCommand extends Command<void> {
218228
}
219229

220230
void usesWebOptions({ required bool verboseHelp }) {
231+
argParser.addMultiOption('web-header',
232+
help: 'Additional key-value pairs that will added by the web server '
233+
'as headers to all responses. Multiple headers can be passed by '
234+
'repeating "--web-header" multiple times.',
235+
valueHelp: 'X-Custom-Header=header-value',
236+
splitCommas: false,
237+
hide: !verboseHelp,
238+
);
221239
argParser.addOption('web-hostname',
222240
defaultsTo: 'localhost',
223241
help:
@@ -1521,6 +1539,31 @@ abstract class FlutterCommand extends Command<void> {
15211539
return dartDefinesSet.toList();
15221540
}
15231541

1542+
1543+
Map<String, String> extractWebHeaders() {
1544+
final Map<String, String> webHeaders = <String, String>{};
1545+
1546+
if (argParser.options.containsKey('web-header')) {
1547+
final List<String> candidates = stringsArg('web-header');
1548+
final List<String> invalidHeaders = <String>[];
1549+
for (final String candidate in candidates) {
1550+
final Match? keyValueMatch = _HttpRegex.httpHeader.firstMatch(candidate);
1551+
if (keyValueMatch == null) {
1552+
invalidHeaders.add(candidate);
1553+
continue;
1554+
}
1555+
1556+
webHeaders[keyValueMatch.group(1)!] = keyValueMatch.group(2)!;
1557+
}
1558+
1559+
if (invalidHeaders.isNotEmpty) {
1560+
throwToolExit('Invalid web headers: ${invalidHeaders.join(', ')}');
1561+
}
1562+
}
1563+
1564+
return webHeaders;
1565+
}
1566+
15241567
void _registerSignalHandlers(String commandPath, DateTime startTime) {
15251568
void handler(io.ProcessSignal s) {
15261569
globals.cache.releaseLock();

packages/flutter_tools/test/commands.shard/hermetic/run_test.dart

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,99 @@ void main() {
919919
ProcessManager: () => FakeProcessManager.any(),
920920
});
921921
});
922+
923+
group('--web-header', () {
924+
setUp(() {
925+
fileSystem.file('lib/main.dart').createSync(recursive: true);
926+
fileSystem.file('pubspec.yaml').createSync();
927+
fileSystem.file('.packages').createSync();
928+
final FakeDevice device = FakeDevice(isLocalEmulator: true, platformType: PlatformType.android);
929+
testDeviceManager.devices = <Device>[device];
930+
});
931+
932+
testUsingContext('can accept simple, valid values', () async {
933+
final RunCommand command = RunCommand();
934+
await expectLater(
935+
() => createTestCommandRunner(command).run(<String>[
936+
'run',
937+
'--no-pub', '--no-hot',
938+
'--web-header', 'foo = bar',
939+
]), throwsToolExit());
940+
941+
final DebuggingOptions options = await command.createDebuggingOptions(true);
942+
expect(options.webHeaders, <String, String>{'foo': 'bar'});
943+
}, overrides: <Type, Generator>{
944+
FileSystem: () => fileSystem,
945+
ProcessManager: () => FakeProcessManager.any(),
946+
Logger: () => BufferLogger.test(),
947+
DeviceManager: () => testDeviceManager,
948+
});
949+
950+
testUsingContext('throws a ToolExit when no value is provided', () async {
951+
final RunCommand command = RunCommand();
952+
await expectLater(
953+
() => createTestCommandRunner(command).run(<String>[
954+
'run',
955+
'--no-pub', '--no-hot',
956+
'--web-header',
957+
'foo',
958+
]), throwsToolExit(message: 'Invalid web headers: foo'));
959+
960+
await expectLater(
961+
() => command.createDebuggingOptions(true),
962+
throwsToolExit(),
963+
);
964+
}, overrides: <Type, Generator>{
965+
FileSystem: () => fileSystem,
966+
ProcessManager: () => FakeProcessManager.any(),
967+
Logger: () => BufferLogger.test(),
968+
DeviceManager: () => testDeviceManager,
969+
});
970+
971+
testUsingContext('throws a ToolExit when value includes delimiter characters', () async {
972+
fileSystem.file('lib/main.dart').createSync(recursive: true);
973+
fileSystem.file('pubspec.yaml').createSync();
974+
fileSystem.file('.packages').createSync();
975+
976+
final RunCommand command = RunCommand();
977+
await expectLater(
978+
() => createTestCommandRunner(command).run(<String>[
979+
'run',
980+
'--no-pub', '--no-hot',
981+
'--web-header', 'hurray/headers=flutter',
982+
]), throwsToolExit());
983+
984+
await expectLater(
985+
() => command.createDebuggingOptions(true),
986+
throwsToolExit(message: 'Invalid web headers: hurray/headers=flutter'),
987+
);
988+
}, overrides: <Type, Generator>{
989+
FileSystem: () => fileSystem,
990+
ProcessManager: () => FakeProcessManager.any(),
991+
Logger: () => BufferLogger.test(),
992+
DeviceManager: () => testDeviceManager,
993+
});
994+
995+
testUsingContext('accepts headers with commas in them', () async {
996+
final RunCommand command = RunCommand();
997+
await expectLater(
998+
() => createTestCommandRunner(command).run(<String>[
999+
'run',
1000+
'--no-pub', '--no-hot',
1001+
'--web-header', 'hurray=flutter,flutter=hurray',
1002+
]), throwsToolExit());
1003+
1004+
final DebuggingOptions options = await command.createDebuggingOptions(true);
1005+
expect(options.webHeaders, <String, String>{
1006+
'hurray': 'flutter,flutter=hurray'
1007+
});
1008+
}, overrides: <Type, Generator>{
1009+
FileSystem: () => fileSystem,
1010+
ProcessManager: () => FakeProcessManager.any(),
1011+
Logger: () => BufferLogger.test(),
1012+
DeviceManager: () => testDeviceManager,
1013+
});
1014+
});
9221015
});
9231016

9241017
group('dart-defines and web-renderer options', () {

packages/flutter_tools/test/general.shard/web/devfs_web_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ void main() {
680680
entrypoint: Uri.base,
681681
testMode: true,
682682
expressionCompiler: null, // ignore: avoid_redundant_argument_values
683+
extraHeaders: const <String, String>{},
683684
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
684685
nullSafetyMode: NullSafetyMode.unsound,
685686
);
@@ -792,6 +793,7 @@ void main() {
792793
entrypoint: Uri.base,
793794
testMode: true,
794795
expressionCompiler: null, // ignore: avoid_redundant_argument_values
796+
extraHeaders: const <String, String>{},
795797
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
796798
nullSafetyMode: NullSafetyMode.sound,
797799
);
@@ -901,6 +903,7 @@ void main() {
901903
entrypoint: Uri.base,
902904
testMode: true,
903905
expressionCompiler: null,
906+
extraHeaders: const <String, String>{},
904907
chromiumLauncher: null,
905908
nullSafetyMode: NullSafetyMode.sound,
906909
);
@@ -957,6 +960,7 @@ void main() {
957960
entrypoint: Uri.base,
958961
testMode: true,
959962
expressionCompiler: null, // ignore: avoid_redundant_argument_values
963+
extraHeaders: const <String, String>{},
960964
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
961965
nullAssertions: true,
962966
nativeNullAssertions: true,
@@ -1001,6 +1005,7 @@ void main() {
10011005
entrypoint: Uri.base,
10021006
testMode: true,
10031007
expressionCompiler: null, // ignore: avoid_redundant_argument_values
1008+
extraHeaders: const <String, String>{},
10041009
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
10051010
nullSafetyMode: NullSafetyMode.sound,
10061011
);
@@ -1044,6 +1049,7 @@ void main() {
10441049
entrypoint: Uri.base,
10451050
testMode: true,
10461051
expressionCompiler: null, // ignore: avoid_redundant_argument_values
1052+
extraHeaders: const <String, String>{},
10471053
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
10481054
nullSafetyMode: NullSafetyMode.sound,
10491055
);
@@ -1075,13 +1081,45 @@ void main() {
10751081
false,
10761082
Uri.base,
10771083
null,
1084+
const <String, String>{},
10781085
NullSafetyMode.unsound,
10791086
testMode: true);
10801087

10811088
expect(webAssetServer.defaultResponseHeaders['x-frame-options'], null);
10821089
await webAssetServer.dispose();
10831090
});
10841091

1092+
test('passes on extra headers', () async {
1093+
const String extraHeaderKey = 'hurray';
1094+
const String extraHeaderValue = 'flutter';
1095+
final WebAssetServer webAssetServer = await WebAssetServer.start(
1096+
null,
1097+
'localhost',
1098+
0,
1099+
null,
1100+
true,
1101+
true,
1102+
true,
1103+
const BuildInfo(
1104+
BuildMode.debug,
1105+
'',
1106+
treeShakeIcons: false,
1107+
),
1108+
false,
1109+
false,
1110+
Uri.base,
1111+
null,
1112+
const <String, String>{
1113+
extraHeaderKey: extraHeaderValue,
1114+
},
1115+
NullSafetyMode.unsound,
1116+
testMode: true);
1117+
1118+
expect(webAssetServer.defaultResponseHeaders[extraHeaderKey], <String>[extraHeaderValue]);
1119+
1120+
await webAssetServer.dispose();
1121+
});
1122+
10851123
test('WebAssetServer responds to POST requests with 404 not found', () => testbed.run(() async {
10861124
final Response response = await webAssetServer.handleRequest(
10871125
Request('POST', Uri.parse('http://foobar/something')),
@@ -1147,6 +1185,7 @@ void main() {
11471185
entrypoint: Uri.base,
11481186
testMode: true,
11491187
expressionCompiler: null, // ignore: avoid_redundant_argument_values
1188+
extraHeaders: const <String, String>{},
11501189
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
11511190
nullSafetyMode: NullSafetyMode.unsound,
11521191
);

0 commit comments

Comments
 (0)