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
final List<String> webBrowserFlags = featureFlags.isWebEnabled
? stringsArg(FlutterOptions.kWebBrowserFlag)
: const <String>[];
final Map<String, String> webHeaders = featureFlags.isWebEnabled
? extractWebHeaders()
: const <String, String>{};
if (buildInfo.mode.isRelease) {
return DebuggingOptions.disabled(
buildInfo,
......@@ -252,6 +257,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'),
webBrowserDebugPort: webBrowserDebugPort,
webBrowserFlags: webBrowserFlags,
webHeaders: webHeaders,
enableImpeller: enableImpeller,
enableVulkanValidation: enableVulkanValidation,
impellerForceGL: impellerForceGL,
......@@ -298,6 +304,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment
webBrowserFlags: webBrowserFlags,
webEnableExpressionEvaluation: featureFlags.isWebEnabled && boolArg('web-enable-expression-evaluation'),
webLaunchUrl: featureFlags.isWebEnabled ? stringArg('web-launch-url') : null,
webHeaders: webHeaders,
vmserviceOutFile: stringArg('vmservice-out-file'),
fastStart: argParser.options.containsKey('fast-start')
&& boolArg('fast-start')
......
......@@ -959,6 +959,7 @@ class DebuggingOptions {
this.webBrowserDebugPort,
this.webBrowserFlags = const <String>[],
this.webEnableExpressionEvaluation = false,
this.webHeaders = const <String, String>{},
this.webLaunchUrl,
this.vmserviceOutFile,
this.fastStart = false,
......@@ -986,6 +987,7 @@ class DebuggingOptions {
this.webBrowserDebugPort,
this.webBrowserFlags = const <String>[],
this.webLaunchUrl,
this.webHeaders = const <String, String>{},
this.cacheSkSL = false,
this.traceAllowlist,
this.enableImpeller = ImpellerStatus.platformDefault,
......@@ -1061,6 +1063,7 @@ class DebuggingOptions {
required this.webBrowserDebugPort,
required this.webBrowserFlags,
required this.webEnableExpressionEvaluation,
required this.webHeaders,
required this.webLaunchUrl,
required this.vmserviceOutFile,
required this.fastStart,
......@@ -1141,6 +1144,9 @@ class DebuggingOptions {
/// Allow developers to customize the browser's launch URL
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.
final String? vmserviceOutFile;
final bool fastStart;
......@@ -1246,6 +1252,7 @@ class DebuggingOptions {
'webBrowserFlags': webBrowserFlags,
'webEnableExpressionEvaluation': webEnableExpressionEvaluation,
'webLaunchUrl': webLaunchUrl,
'webHeaders': webHeaders,
'vmserviceOutFile': vmserviceOutFile,
'fastStart': fastStart,
'nullAssertions': nullAssertions,
......@@ -1297,6 +1304,7 @@ class DebuggingOptions {
webBrowserDebugPort: json['webBrowserDebugPort'] as int?,
webBrowserFlags: (json['webBrowserFlags']! as List<dynamic>).cast<String>(),
webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool,
webHeaders: (json['webHeaders']! as Map<dynamic, dynamic>).cast<String, String>(),
webLaunchUrl: json['webLaunchUrl'] as String?,
vmserviceOutFile: json['vmserviceOutFile'] as String?,
fastStart: json['fastStart']! as bool,
......
......@@ -189,6 +189,7 @@ class WebAssetServer implements AssetReader {
bool enableDds,
Uri entrypoint,
ExpressionCompiler? expressionCompiler,
Map<String, String> extraHeaders,
NullSafetyMode nullSafetyMode, {
bool testMode = false,
DwdsLauncher dwdsLauncher = Dwds.start,
......@@ -217,6 +218,10 @@ class WebAssetServer implements AssetReader {
// Allow rendering in a iframe.
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 Map<String, String> digests = <String, String>{};
final Map<String, String> modules = <String, String>{};
......@@ -653,6 +658,7 @@ class WebDevFS implements DevFS {
required this.enableDds,
required this.entrypoint,
required this.expressionCompiler,
required this.extraHeaders,
required this.chromiumLauncher,
required this.nullAssertions,
required this.nativeNullAssertions,
......@@ -670,6 +676,7 @@ class WebDevFS implements DevFS {
final BuildInfo buildInfo;
final bool enableDwds;
final bool enableDds;
final Map<String, String> extraHeaders;
final bool testMode;
final ExpressionCompiler? expressionCompiler;
final ChromiumLauncher? chromiumLauncher;
......@@ -772,6 +779,7 @@ class WebDevFS implements DevFS {
enableDds,
entrypoint,
expressionCompiler,
extraHeaders,
nullSafetyMode,
testMode: testMode,
);
......
......@@ -297,6 +297,7 @@ Please provide a valid TCP port (an integer between 0 and 65535, inclusive).
enableDds: debuggingOptions.enableDds,
entrypoint: _fileSystem.file(target).uri,
expressionCompiler: expressionCompiler,
extraHeaders: debuggingOptions.webHeaders,
chromiumLauncher: _chromiumLauncher,
nullAssertions: debuggingOptions.nullAssertions,
nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode,
......
......@@ -59,6 +59,16 @@ abstract class DotEnvRegex {
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 {
success,
warning,
......@@ -218,6 +228,14 @@ abstract class FlutterCommand extends Command<void> {
}
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',
defaultsTo: 'localhost',
help:
......@@ -1521,6 +1539,31 @@ abstract class FlutterCommand extends Command<void> {
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 handler(io.ProcessSignal s) {
globals.cache.releaseLock();
......
......@@ -919,6 +919,99 @@ void main() {
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', () {
......
......@@ -680,6 +680,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.unsound,
);
......@@ -792,6 +793,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound,
);
......@@ -901,6 +903,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null,
extraHeaders: const <String, String>{},
chromiumLauncher: null,
nullSafetyMode: NullSafetyMode.sound,
);
......@@ -957,6 +960,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullAssertions: true,
nativeNullAssertions: true,
......@@ -1001,6 +1005,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound,
);
......@@ -1044,6 +1049,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
nullSafetyMode: NullSafetyMode.sound,
);
......@@ -1075,6 +1081,7 @@ void main() {
false,
Uri.base,
null,
const <String, String>{},
NullSafetyMode.unsound,
testMode: true);
......@@ -1082,6 +1089,37 @@ void main() {
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 {
final Response response = await webAssetServer.handleRequest(
Request('POST', Uri.parse('http://foobar/something')),
......@@ -1147,6 +1185,7 @@ void main() {
entrypoint: Uri.base,
testMode: true,
expressionCompiler: null, // ignore: avoid_redundant_argument_values
extraHeaders: const <String, String>{},
chromiumLauncher: null, // ignore: avoid_redundant_argument_values
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