From 9fdd02ef65b916dd98eb6877e88c92e006c743f3 Mon Sep 17 00:00:00 2001 From: Isaque Neves Date: Wed, 30 Jul 2025 18:19:16 -0300 Subject: [PATCH 1/2] feat(serve): Add --spa-fallback flag for SPA routing When developing Single Page Applications (SPAs) that use client-side routing with the HTML5 History API (e.g., Angular's PathLocationStrategy), refreshing the page on a deep link results in a 404 error because the server can't find a corresponding file for the route. This change introduces a --spa-fallback flag to the webdev serve command. When enabled, the development server will serve the root index.html file for any GET request that would otherwise result in a 404, as long as the path does not appear to be a direct file asset (i.e., does not contain a file extension). This allows the client-side router to take over and handle the request, enabling a seamless development workflow for modern web applications. --- webdev/lib/src/command/configuration.dart | 16 ++++++++++++++-- webdev/lib/src/command/serve_command.dart | 4 ++++ webdev/lib/src/serve/webdev_server.dart | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) 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..299ecffc7 100644 --- a/webdev/lib/src/serve/webdev_server.dart +++ b/webdev/lib/src/serve/webdev_server.dart @@ -195,6 +195,25 @@ class WebDevServer { cascade = cascade.add(assetHandler); } + if (options.configuration.spaFallback) { + FutureOr spaFallbackHandler(Request request) async { + final uri = request.requestedUri; + final hasExtension = + uri.pathSegments.isNotEmpty && uri.pathSegments.last.contains('.'); + if (request.method != 'GET' || hasExtension) { + return Response.notFound('Not Found'); + } + final indexResponse = + await assetHandler(request.change(path: 'index.html')); + + return indexResponse.statusCode == 200 + ? indexResponse + : Response.notFound('Not Found'); + } + + cascade = cascade.add(spaFallbackHandler); + } + final hostname = options.configuration.hostname; final tlsCertChain = options.configuration.tlsCertChain ?? ''; final tlsCertKey = options.configuration.tlsCertKey ?? ''; From ebac34b3f80d5d8c315aa19d1f6a6667955ccf35 Mon Sep 17 00:00:00 2001 From: Isaque Neves Date: Thu, 31 Jul 2025 17:14:18 -0300 Subject: [PATCH 2/2] fix(spa-fallback): always return index.html after refresh & 304 cache hit * Strip 'If-None-Match' / 'If-Modified-Since' before proxying the request to index.html, forcing the asset-server to answer with 200 + body. * Treat 304 responses as success so the fallback still works when the file is cached. * Ensure Content-Type: text/html; charset=utf-8 is set to prevent MIME errors in the browser. * Leave static asset requests (.js, .css, images, etc.) untouched. This guarantees deep-link routes keep working after multiple page reloads and removes the 'Not Found / text/plain MIME type' error seen on the second refresh. --- webdev/lib/src/serve/webdev_server.dart | 36 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/webdev/lib/src/serve/webdev_server.dart b/webdev/lib/src/serve/webdev_server.dart index 299ecffc7..48f7c5582 100644 --- a/webdev/lib/src/serve/webdev_server.dart +++ b/webdev/lib/src/serve/webdev_server.dart @@ -197,18 +197,36 @@ class WebDevServer { if (options.configuration.spaFallback) { FutureOr spaFallbackHandler(Request request) async { - final uri = request.requestedUri; - final hasExtension = - uri.pathSegments.isNotEmpty && uri.pathSegments.last.contains('.'); - if (request.method != 'GET' || hasExtension) { + final hasExt = request.url.pathSegments.isNotEmpty && + request.url.pathSegments.last.contains('.'); + if (request.method != 'GET' || hasExt) { return Response.notFound('Not Found'); } - final indexResponse = - await assetHandler(request.change(path: 'index.html')); - return indexResponse.statusCode == 200 - ? indexResponse - : 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);