Unverified Commit 48eee14f authored by Aran Donohue's avatar Aran Donohue Committed by GitHub

Support --web-header option for flutter run (#136297)

Adds support for a new --web-header option to flutter run.

Creates a workaround for https://github.com/flutter/flutter/issues/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.

https://github.com/flutter/flutter/issues/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.
parent b136ddc6
...@@ -239,6 +239,11 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment ...@@ -239,6 +239,11 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
final List<String> webBrowserFlags = featureFlags.isWebEnabled final List<String> webBrowserFlags = featureFlags.isWebEnabled
? stringsArg(FlutterOptions.kWebBrowserFlag) ? stringsArg(FlutterOptions.kWebBrowserFlag)
: const <String>[]; : const <String>[];
final Map<String, String> webHeaders = featureFlags.isWebEnabled
? extractWebHeaders()
: const <String, String>{};
if (buildInfo.mode.isRelease) { if (buildInfo.mode.isRelease) {
return DebuggingOptions.disabled( return DebuggingOptions.disabled(
buildInfo, buildInfo,
...@@ -252,6 +257,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment ...@@ -252,6 +257,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'), webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'),
webBrowserDebugPort: webBrowserDebugPort, webBrowserDebugPort: webBrowserDebugPort,
webBrowserFlags: webBrowserFlags, webBrowserFlags: webBrowserFlags,
webHeaders: webHeaders,
enableImpeller: enableImpeller, enableImpeller: enableImpeller,
enableVulkanValidation: enableVulkanValidation, enableVulkanValidation: enableVulkanValidation,
impellerForceGL: impellerForceGL, impellerForceGL: impellerForceGL,
...@@ -298,6 +304,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment ...@@ -298,6 +304,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
webBrowserFlags: webBrowserFlags, webBrowserFlags: webBrowserFlags,
webEnableExpressionEvaluation: featureFlags.isWebEnabled && boolArg('web-enable-expression-evaluation'), webEnableExpressionEvaluation: featureFlags.isWebEnabled && boolArg('web-enable-expression-evaluation'),
webLaunchUrl: featureFlags.isWebEnabled ? stringArg('web-launch-url') : null, webLaunchUrl: featureFlags.isWebEnabled ? stringArg('web-launch-url') : null,
webHeaders: webHeaders,
vmserviceOutFile: stringArg('vmservice-out-file'), vmserviceOutFile: stringArg('vmservice-out-file'),
fastStart: argParser.options.containsKey('fast-start') fastStart: argParser.options.containsKey('fast-start')
&& boolArg('fast-start') && boolArg('fast-start')
......
...@@ -959,6 +959,7 @@ class DebuggingOptions { ...@@ -959,6 +959,7 @@ class DebuggingOptions {
this.webBrowserDebugPort, this.webBrowserDebugPort,
this.webBrowserFlags = const <String>[], this.webBrowserFlags = const <String>[],
this.webEnableExpressionEvaluation = false, this.webEnableExpressionEvaluation = false,
this.webHeaders = const <String, String>{},
this.webLaunchUrl, this.webLaunchUrl,
this.vmserviceOutFile, this.vmserviceOutFile,
this.fastStart = false, this.fastStart = false,
...@@ -986,6 +987,7 @@ class DebuggingOptions { ...@@ -986,6 +987,7 @@ class DebuggingOptions {
this.webBrowserDebugPort, this.webBrowserDebugPort,
this.webBrowserFlags = const <String>[], this.webBrowserFlags = const <String>[],
this.webLaunchUrl, this.webLaunchUrl,
this.webHeaders = const <String, String>{},
this.cacheSkSL = false, this.cacheSkSL = false,
this.traceAllowlist, this.traceAllowlist,
this.enableImpeller = ImpellerStatus.platformDefault, this.enableImpeller = ImpellerStatus.platformDefault,
...@@ -1061,6 +1063,7 @@ class DebuggingOptions { ...@@ -1061,6 +1063,7 @@ class DebuggingOptions {
required this.webBrowserDebugPort, required this.webBrowserDebugPort,
required this.webBrowserFlags, required this.webBrowserFlags,
required this.webEnableExpressionEvaluation, required this.webEnableExpressionEvaluation,
required this.webHeaders,
required this.webLaunchUrl, required this.webLaunchUrl,
required this.vmserviceOutFile, required this.vmserviceOutFile,
required this.fastStart, required this.fastStart,
...@@ -1141,6 +1144,9 @@ class DebuggingOptions { ...@@ -1141,6 +1144,9 @@ class DebuggingOptions {
/// Allow developers to customize the browser's launch URL /// Allow developers to customize the browser's launch URL
final String? webLaunchUrl; final String? webLaunchUrl;
/// Allow developers to add custom headers to web server
final Map<String, String> webHeaders;
/// A file where the VM Service URL should be written after the application is started. /// A file where the VM Service URL should be written after the application is started.
final String? vmserviceOutFile; final String? vmserviceOutFile;
final bool fastStart; final bool fastStart;
...@@ -1246,6 +1252,7 @@ class DebuggingOptions { ...@@ -1246,6 +1252,7 @@ class DebuggingOptions {
'webBrowserFlags': webBrowserFlags, 'webBrowserFlags': webBrowserFlags,
'webEnableExpressionEvaluation': webEnableExpressionEvaluation, 'webEnableExpressionEvaluation': webEnableExpressionEvaluation,
'webLaunchUrl': webLaunchUrl, 'webLaunchUrl': webLaunchUrl,
'webHeaders': webHeaders,
'vmserviceOutFile': vmserviceOutFile, 'vmserviceOutFile': vmserviceOutFile,
'fastStart': fastStart, 'fastStart': fastStart,
'nullAssertions': nullAssertions, 'nullAssertions': nullAssertions,
...@@ -1297,6 +1304,7 @@ class DebuggingOptions { ...@@ -1297,6 +1304,7 @@ class DebuggingOptions {
webBrowserDebugPort: json['webBrowserDebugPort'] as int?, webBrowserDebugPort: json['webBrowserDebugPort'] as int?,
webBrowserFlags: (json['webBrowserFlags']! as List<dynamic>).cast<String>(), webBrowserFlags: (json['webBrowserFlags']! as List<dynamic>).cast<String>(),
webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool, webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool,
webHeaders: (json['webHeaders']! as Map<dynamic, dynamic>).cast<String, String>(),
webLaunchUrl: json['webLaunchUrl'] as String?, webLaunchUrl: json['webLaunchUrl'] as String?,
vmserviceOutFile: json['vmserviceOutFile'] as String?, vmserviceOutFile: json['vmserviceOutFile'] as String?,
fastStart: json['fastStart']! as bool, fastStart: json['fastStart']! as bool,
......
...@@ -189,6 +189,7 @@ class WebAssetServer implements AssetReader { ...@@ -189,6 +189,7 @@ class WebAssetServer implements AssetReader {
bool enableDds, bool enableDds,
Uri entrypoint, Uri entrypoint,
ExpressionCompiler? expressionCompiler, ExpressionCompiler? expressionCompiler,
Map<String, String> extraHeaders,
NullSafetyMode nullSafetyMode, { NullSafetyMode nullSafetyMode, {
bool testMode = false, bool testMode = false,
DwdsLauncher dwdsLauncher = Dwds.start, DwdsLauncher dwdsLauncher = Dwds.start,
...@@ -217,6 +218,10 @@ class WebAssetServer implements AssetReader { ...@@ -217,6 +218,10 @@ class WebAssetServer implements AssetReader {
// Allow rendering in a iframe. // Allow rendering in a iframe.
httpServer!.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN'); httpServer!.defaultResponseHeaders.remove('x-frame-options', 'SAMEORIGIN');
for (final MapEntry<String, String> header in extraHeaders.entries) {
httpServer.defaultResponseHeaders.add(header.key, header.value);
}
final PackageConfig packageConfig = buildInfo.packageConfig; final PackageConfig packageConfig = buildInfo.packageConfig;
final Map<String, String> digests = <String, String>{}; final Map<String, String> digests = <String, String>{};
final Map<String, String> modules = <String, String>{}; final Map<String, String> modules = <String, String>{};
...@@ -653,6 +658,7 @@ class WebDevFS implements DevFS { ...@@ -653,6 +658,7 @@ class WebDevFS implements DevFS {
required this.enableDds, required this.enableDds,
required this.entrypoint, required this.entrypoint,
required this.expressionCompiler, required this.expressionCompiler,
required this.extraHeaders,
required this.chromiumLauncher, required this.chromiumLauncher,
required this.nullAssertions, required this.nullAssertions,
required this.nativeNullAssertions, required this.nativeNullAssertions,
...@@ -670,6 +676,7 @@ class WebDevFS implements DevFS { ...@@ -670,6 +676,7 @@ class WebDevFS implements DevFS {
final BuildInfo buildInfo; final BuildInfo buildInfo;
final bool enableDwds; final bool enableDwds;
final bool enableDds; final bool enableDds;
final Map<String, String> extraHeaders;
final bool testMode; final bool testMode;
final ExpressionCompiler? expressionCompiler; final ExpressionCompiler? expressionCompiler;
final ChromiumLauncher? chromiumLauncher; final ChromiumLauncher? chromiumLauncher;
...@@ -772,6 +779,7 @@ class WebDevFS implements DevFS { ...@@ -772,6 +779,7 @@ class WebDevFS implements DevFS {
enableDds, enableDds,
entrypoint, entrypoint,
expressionCompiler, expressionCompiler,
extraHeaders,
nullSafetyMode, nullSafetyMode,
testMode: testMode, testMode: testMode,
); );
......
...@@ -297,6 +297,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive). ...@@ -297,6 +297,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive).
enableDds: debuggingOptions.enableDds, enableDds: debuggingOptions.enableDds,
entrypoint: _fileSystem.file(target).uri, entrypoint: _fileSystem.file(target).uri,
expressionCompiler: expressionCompiler, expressionCompiler: expressionCompiler,
extraHeaders: debuggingOptions.webHeaders,
chromiumLauncher: _chromiumLauncher, chromiumLauncher: _chromiumLauncher,
nullAssertions: debuggingOptions.nullAssertions, nullAssertions: debuggingOptions.nullAssertions,
nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode, nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode,
......
...@@ -59,6 +59,16 @@ abstract class DotEnvRegex { ...@@ -59,6 +59,16 @@ abstract class DotEnvRegex {
static final RegExp unquotedValue = RegExp(r'^([^#\n\s]*)\s*(?:\s*#\s*(.*))?$'); static final RegExp unquotedValue = RegExp(r'^([^#\n\s]*)\s*(?:\s*#\s*(.*))?$');
} }
abstract class _HttpRegex {
// https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
static const String _vchar = r'\x21-\x7E';
static const String _spaceOrTab = r'\x20\x09';
static const String _nonDelimiterVchar = r'\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7A\x7C\x7E';
// --web-header is provided as key=value for consistency with --dart-define
static final RegExp httpHeader = RegExp('^([$_nonDelimiterVchar]+)' r'\s*=\s*' '([$_vchar$_spaceOrTab]+)' r'$');
}
enum ExitStatus { enum ExitStatus {
success, success,
warning, warning,
...@@ -218,6 +228,14 @@ abstract class FlutterCommand extends Command<void> { ...@@ -218,6 +228,14 @@ abstract class FlutterCommand extends Command<void> {
} }
void usesWebOptions({ required bool verboseHelp }) { void usesWebOptions({ required bool verboseHelp }) {
argParser.addMultiOption('web-header',
help: 'Additional key-value pairs that will added by the web server '
'as headers to all responses. Multiple headers can be passed by '
'repeating "--web-header" multiple times.',
valueHelp: 'X-Custom-Header=header-value',
splitCommas: false,
hide: !verboseHelp,
);
argParser.addOption('web-hostname', argParser.addOption('web-hostname',
defaultsTo: 'localhost', defaultsTo: 'localhost',
help: help:
...@@ -1521,6 +1539,31 @@ abstract class FlutterCommand extends Command<void> { ...@@ -1521,6 +1539,31 @@ abstract class FlutterCommand extends Command<void> {
return dartDefinesSet.toList(); return dartDefinesSet.toList();
} }
Map<String, String> extractWebHeaders() {
final Map<String, String> webHeaders = <String, String>{};
if (argParser.options.containsKey('web-header')) {
final List<String> candidates = stringsArg('web-header');
final List<String> invalidHeaders = <String>[];
for (final String candidate in candidates) {
final Match? keyValueMatch = _HttpRegex.httpHeader.firstMatch(candidate);
if (keyValueMatch == null) {
invalidHeaders.add(candidate);
continue;
}
webHeaders[keyValueMatch.group(1)!] = keyValueMatch.group(2)!;
}
if (invalidHeaders.isNotEmpty) {
throwToolExit('Invalid web headers: ${invalidHeaders.join(', ')}');
}
}
return webHeaders;
}
void _registerSignalHandlers(String commandPath, DateTime startTime) { void _registerSignalHandlers(String commandPath, DateTime startTime) {
void handler(io.ProcessSignal s) { void handler(io.ProcessSignal s) {
globals.cache.releaseLock(); globals.cache.releaseLock();
......
...@@ -919,6 +919,99 @@ void main() { ...@@ -919,6 +919,99 @@ void main() {
ProcessManager: () => FakeProcessManager.any(), ProcessManager: () => FakeProcessManager.any(),
}); });
}); });
group('--web-header', () {
setUp(() {
fileSystem.file('lib/main.dart').createSync(recursive: true);
fileSystem.file('pubspec.yaml').createSync();
fileSystem.file('.packages').createSync();
final FakeDevice device = FakeDevice(isLocalEmulator: true, platformType: PlatformType.android);
testDeviceManager.devices = <Device>[device];
});
testUsingContext('can accept simple, valid values', () async {
final RunCommand command = RunCommand();
await expectLater(
() => createTestCommandRunner(command).run(<String>[
'run',
'--no-pub', '--no-hot',
'--web-header', 'foo = bar',
]), throwsToolExit());
final DebuggingOptions options = await command.createDebuggingOptions(true);
expect(options.webHeaders, <String, String>{'foo': 'bar'});
}, overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
Logger: () => BufferLogger.test(),
DeviceManager: () => testDeviceManager,
});
testUsingContext('throws a ToolExit when no value is provided', () async {
final RunCommand command = RunCommand();
await expectLater(
() => createTestCommandRunner(command).run(<String>[
'run',
'--no-pub', '--no-hot',
'--web-header',
'foo',
]), throwsToolExit(message: 'Invalid web headers: foo'));
await expectLater(
() => command.createDebuggingOptions(true),
throwsToolExit(),
);
}, overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
Logger: () => BufferLogger.test(),
DeviceManager: () => testDeviceManager,
});
testUsingContext('throws a ToolExit when value includes delimiter characters', () async {
fileSystem.file('lib/main.dart').createSync(recursive: true);
fileSystem.file('pubspec.yaml').createSync();
fileSystem.file('.packages').createSync();
final RunCommand command = RunCommand();
await expectLater(
() => createTestCommandRunner(command).run(<String>[
'run',
'--no-pub', '--no-hot',
'--web-header', 'hurray/headers=flutter',
]), throwsToolExit());
await expectLater(
() => command.createDebuggingOptions(true),
throwsToolExit(message: 'Invalid web headers: hurray/headers=flutter'),
);
}, overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
Logger: () => BufferLogger.test(),
DeviceManager: () => testDeviceManager,
});
testUsingContext('accepts headers with commas in them', () async {
final RunCommand command = RunCommand();
await expectLater(
() => createTestCommandRunner(command).run(<String>[
'run',
'--no-pub', '--no-hot',
'--web-header', 'hurray=flutter,flutter=hurray',
]), throwsToolExit());
final DebuggingOptions options = await command.createDebuggingOptions(true);
expect(options.webHeaders, <String, String>{
'hurray': 'flutter,flutter=hurray'
});
}, overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
Logger: () => BufferLogger.test(),
DeviceManager: () => testDeviceManager,
});
});
}); });
group('dart-defines and web-renderer options', () { group('dart-defines and web-renderer options', () {
......
...@@ -680,6 +680,7 @@ void main() { ...@@ -680,6 +680,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.unsound, nullSafetyMode: NullSafetyMode.unsound,
); );
...@@ -792,6 +793,7 @@ void main() { ...@@ -792,6 +793,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound, nullSafetyMode: NullSafetyMode.sound,
); );
...@@ -901,6 +903,7 @@ void main() { ...@@ -901,6 +903,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, expressionCompiler: null,
extraHeaders: const <String, String>{},
chromiumLauncher: null, chromiumLauncher: null,
nullSafetyMode: NullSafetyMode.sound, nullSafetyMode: NullSafetyMode.sound,
); );
...@@ -957,6 +960,7 @@ void main() { ...@@ -957,6 +960,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullAssertions: true, nullAssertions: true,
nativeNullAssertions: true, nativeNullAssertions: true,
...@@ -1001,6 +1005,7 @@ void main() { ...@@ -1001,6 +1005,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound, nullSafetyMode: NullSafetyMode.sound,
); );
...@@ -1044,6 +1049,7 @@ void main() { ...@@ -1044,6 +1049,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound, nullSafetyMode: NullSafetyMode.sound,
); );
...@@ -1075,6 +1081,7 @@ void main() { ...@@ -1075,6 +1081,7 @@ void main() {
false, false,
Uri.base, Uri.base,
null, null,
const <String, String>{},
NullSafetyMode.unsound, NullSafetyMode.unsound,
testMode: true); testMode: true);
...@@ -1082,6 +1089,37 @@ void main() { ...@@ -1082,6 +1089,37 @@ void main() {
await webAssetServer.dispose(); await webAssetServer.dispose();
}); });
test('passes on extra headers', () async {
const String extraHeaderKey = 'hurray';
const String extraHeaderValue = 'flutter';
final WebAssetServer webAssetServer = await WebAssetServer.start(
null,
'localhost',
0,
null,
true,
true,
true,
const BuildInfo(
BuildMode.debug,
'',
treeShakeIcons: false,
),
false,
false,
Uri.base,
null,
const <String, String>{
extraHeaderKey: extraHeaderValue,
},
NullSafetyMode.unsound,
testMode: true);
expect(webAssetServer.defaultResponseHeaders[extraHeaderKey], <String>[extraHeaderValue]);
await webAssetServer.dispose();
});
test('WebAssetServer responds to POST requests with 404 not found', () => testbed.run(() async { test('WebAssetServer responds to POST requests with 404 not found', () => testbed.run(() async {
final Response response = await webAssetServer.handleRequest( final Response response = await webAssetServer.handleRequest(
Request('POST', Uri.parse('http://foobar/something')), Request('POST', Uri.parse('http://foobar/something')),
...@@ -1147,6 +1185,7 @@ void main() { ...@@ -1147,6 +1185,7 @@ void main() {
entrypoint: Uri.base, entrypoint: Uri.base,
testMode: true, testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.unsound, nullSafetyMode: NullSafetyMode.unsound,
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment