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.
This commit is contained in:
parent
b136ddc68b
commit
48eee14f0e
@ -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,
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user