diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index 355709527d..39c38b8187 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -239,6 +239,11 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment final List webBrowserFlags = featureFlags.isWebEnabled ? stringsArg(FlutterOptions.kWebBrowserFlag) : const []; + + final Map webHeaders = featureFlags.isWebEnabled + ? extractWebHeaders() + : const {}; + 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') diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 869751e141..26f97eaa8b 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -959,6 +959,7 @@ class DebuggingOptions { this.webBrowserDebugPort, this.webBrowserFlags = const [], this.webEnableExpressionEvaluation = false, + this.webHeaders = const {}, this.webLaunchUrl, this.vmserviceOutFile, this.fastStart = false, @@ -986,6 +987,7 @@ class DebuggingOptions { this.webBrowserDebugPort, this.webBrowserFlags = const [], this.webLaunchUrl, + this.webHeaders = const {}, 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 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).cast(), webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool, + webHeaders: (json['webHeaders']! as Map).cast(), webLaunchUrl: json['webLaunchUrl'] as String?, vmserviceOutFile: json['vmserviceOutFile'] as String?, fastStart: json['fastStart']! as bool, diff --git a/packages/flutter_tools/lib/src/isolated/devfs_web.dart b/packages/flutter_tools/lib/src/isolated/devfs_web.dart index f69e5b18f7..6e6663f9a5 100644 --- a/packages/flutter_tools/lib/src/isolated/devfs_web.dart +++ b/packages/flutter_tools/lib/src/isolated/devfs_web.dart @@ -189,6 +189,7 @@ class WebAssetServer implements AssetReader { bool enableDds, Uri entrypoint, ExpressionCompiler? expressionCompiler, + Map 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 header in extraHeaders.entries) { + httpServer.defaultResponseHeaders.add(header.key, header.value); + } + final PackageConfig packageConfig = buildInfo.packageConfig; final Map digests = {}; final Map modules = {}; @@ -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 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, ); diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart index 2c81eb2c91..0cfba30966 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart @@ -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, diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 1d99c9a29e..4a07581a90 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -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 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 { return dartDefinesSet.toList(); } + + Map extractWebHeaders() { + final Map webHeaders = {}; + + if (argParser.options.containsKey('web-header')) { + final List candidates = stringsArg('web-header'); + final List invalidHeaders = []; + 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(); diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart index 81ec6c10c0..aeb871bd26 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart @@ -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]; + }); + + testUsingContext('can accept simple, valid values', () async { + final RunCommand command = RunCommand(); + await expectLater( + () => createTestCommandRunner(command).run([ + 'run', + '--no-pub', '--no-hot', + '--web-header', 'foo = bar', + ]), throwsToolExit()); + + final DebuggingOptions options = await command.createDebuggingOptions(true); + expect(options.webHeaders, {'foo': 'bar'}); + }, overrides: { + 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([ + 'run', + '--no-pub', '--no-hot', + '--web-header', + 'foo', + ]), throwsToolExit(message: 'Invalid web headers: foo')); + + await expectLater( + () => command.createDebuggingOptions(true), + throwsToolExit(), + ); + }, overrides: { + 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([ + '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: { + 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([ + 'run', + '--no-pub', '--no-hot', + '--web-header', 'hurray=flutter,flutter=hurray', + ]), throwsToolExit()); + + final DebuggingOptions options = await command.createDebuggingOptions(true); + expect(options.webHeaders, { + 'hurray': 'flutter,flutter=hurray' + }); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + Logger: () => BufferLogger.test(), + DeviceManager: () => testDeviceManager, + }); + }); }); group('dart-defines and web-renderer options', () { diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart index 6936829941..77df3bc315 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart @@ -680,6 +680,7 @@ void main() { entrypoint: Uri.base, testMode: true, expressionCompiler: null, // ignore: avoid_redundant_argument_values + extraHeaders: const {}, 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 {}, 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 {}, 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 {}, 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 {}, 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 {}, chromiumLauncher: null, // ignore: avoid_redundant_argument_values nullSafetyMode: NullSafetyMode.sound, ); @@ -1075,6 +1081,7 @@ void main() { false, Uri.base, null, + const {}, 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 { + extraHeaderKey: extraHeaderValue, + }, + NullSafetyMode.unsound, + testMode: true); + + expect(webAssetServer.defaultResponseHeaders[extraHeaderKey], [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 {}, chromiumLauncher: null, // ignore: avoid_redundant_argument_values nullSafetyMode: NullSafetyMode.unsound, );