diff --git a/webdev/lib/src/command/configuration.dart b/webdev/lib/src/command/configuration.dart index 07f532261..e3036c384 100644 --- a/webdev/lib/src/command/configuration.dart +++ b/webdev/lib/src/command/configuration.dart @@ -38,6 +38,7 @@ const disableDdsFlag = 'disable-dds'; const enableExperimentOption = 'enable-experiment'; const canaryFeaturesFlag = 'canary'; const offlineFlag = 'offline'; +const spaFallbackFlag = 'spa-fallback'; ReloadConfiguration _parseReloadConfiguration(ArgResults argResults) { var auto = argResults.options.contains(autoOption) @@ -109,6 +110,7 @@ class Configuration { final List? _experiments; final bool? _canaryFeatures; final bool? _offline; + final bool? _spaFallback; Configuration({ bool? autoRun, @@ -136,6 +138,7 @@ class Configuration { List? experiments, bool? canaryFeatures, bool? offline, + bool? spaFallback, }) : _autoRun = autoRun, _chromeDebugPort = chromeDebugPort, _debugExtension = debugExtension, @@ -158,7 +161,8 @@ class Configuration { _nullSafety = nullSafety, _experiments = experiments, _canaryFeatures = canaryFeatures, - _offline = offline { + _offline = offline, + _spaFallback = spaFallback { _validateConfiguration(); } @@ -234,7 +238,8 @@ class Configuration { nullSafety: other._nullSafety ?? _nullSafety, experiments: other._experiments ?? _experiments, canaryFeatures: other._canaryFeatures ?? _canaryFeatures, - offline: other._offline ?? _offline); + offline: other._offline ?? _offline, + spaFallback: other._spaFallback ?? _spaFallback); factory Configuration.noInjectedClientDefaults() => Configuration(autoRun: false, debug: false, debugExtension: false); @@ -291,6 +296,8 @@ class Configuration { bool get offline => _offline ?? false; + bool get spaFallback => _spaFallback ?? false; + /// Returns a new configuration with values updated from the parsed args. static Configuration fromArgs(ArgResults? argResults, {Configuration? defaultConfiguration}) { @@ -419,6 +426,10 @@ class Configuration { ? argResults[offlineFlag] as bool? : defaultConfiguration.verbose; + final spaFallback = argResults.options.contains(spaFallbackFlag) + ? argResults[spaFallbackFlag] as bool? + : defaultConfiguration.spaFallback; + return Configuration( autoRun: defaultConfiguration.autoRun, chromeDebugPort: chromeDebugPort, @@ -445,6 +456,7 @@ class Configuration { experiments: experiments, canaryFeatures: canaryFeatures, offline: offline, + spaFallback: spaFallback, ); } } diff --git a/webdev/lib/src/command/serve_command.dart b/webdev/lib/src/command/serve_command.dart index 3c32817e8..c2337f19b 100644 --- a/webdev/lib/src/command/serve_command.dart +++ b/webdev/lib/src/command/serve_command.dart @@ -75,6 +75,10 @@ refresh: Performs a full page refresh. ..addFlag(logRequestsFlag, negatable: false, help: 'Enables logging for each request to the server.') + ..addFlag('spa-fallback', + negatable: false, + help: 'Serves index.html for any 404 from a non-asset request. ' + 'Useful for single-page applications with client-side routing.') ..addOption(tlsCertChainFlag, help: 'The file location to a TLS Certificate to create an HTTPs server.\n' diff --git a/webdev/lib/src/serve/webdev_server.dart b/webdev/lib/src/serve/webdev_server.dart index e166503f6..48f7c5582 100644 --- a/webdev/lib/src/serve/webdev_server.dart +++ b/webdev/lib/src/serve/webdev_server.dart @@ -195,6 +195,43 @@ class WebDevServer { cascade = cascade.add(assetHandler); } + if (options.configuration.spaFallback) { + FutureOr spaFallbackHandler(Request request) async { + final hasExt = request.url.pathSegments.isNotEmpty && + request.url.pathSegments.last.contains('.'); + if (request.method != 'GET' || hasExt) { + return Response.notFound('Not Found'); + } + + final indexUri = + request.requestedUri.replace(path: 'index.html', query: ''); + + final cleanHeaders = Map.of(request.headers) + ..remove('if-none-match') + ..remove('if-modified-since'); + + final proxiedReq = Request( + 'GET', + indexUri, + headers: cleanHeaders, + context: request.context, + protocolVersion: request.protocolVersion, + ); + + final resp = await assetHandler(proxiedReq); + + if (resp.statusCode != 200 && resp.statusCode != 304) { + return Response.notFound('Not Found'); + } + return resp.change(headers: { + ...resp.headers, + 'content-type': 'text/html; charset=utf-8', + }); + } + + cascade = cascade.add(spaFallbackHandler); + } + final hostname = options.configuration.hostname; final tlsCertChain = options.configuration.tlsCertChain ?? ''; final tlsCertKey = options.configuration.tlsCertKey ?? '';