From 0e326d716bfbb135f282f31e58aa4073e50bc7c0 Mon Sep 17 00:00:00 2001 From: Riajul Islam Date: Tue, 2 Sep 2025 07:40:22 +0000 Subject: [PATCH 1/2] feat(server): add configurable SPA fallback for routes with dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new configuration option `routeWithFallback` to CapacitorConfig.server that enables fallback to index.html for routes containing dots when the requested file doesn't exist. This solves the issue where SPA routes like /@user.name or /api/data.json are incorrectly treated as file requests, returning 404 instead of serving index.html for client-side routing. The feature is disabled by default to maintain backward compatibility. When enabled: - iOS: Checks if the file exists before serving, falls back to index.html if not found - Android: Catches IOException when file not found, serves index.html as fallback Fixes #8085 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../main/java/com/getcapacitor/CapConfig.java | 13 +++++++++++++ .../com/getcapacitor/WebViewLocalServer.java | 16 ++++++++++++++-- cli/src/declarations.ts | 11 +++++++++++ .../Capacitor/CAPBridgeViewController.swift | 1 + .../Capacitor/CAPInstanceConfiguration.h | 1 + .../Capacitor/CAPInstanceConfiguration.m | 2 ++ .../Capacitor/CAPInstanceDescriptor.h | 5 +++++ .../Capacitor/CAPInstanceDescriptor.swift | 3 +++ ios/Capacitor/Capacitor/Router.swift | 13 +++++++++++-- .../Capacitor/WebViewAssetHandler.swift | 7 ++++++- .../CapacitorTests/RouterTests.swift | 19 +++++++++++++++++-- 11 files changed, 84 insertions(+), 7 deletions(-) diff --git a/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java b/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java index 781775ba5..4c4f846eb 100644 --- a/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java +++ b/android/capacitor/src/main/java/com/getcapacitor/CapConfig.java @@ -38,6 +38,7 @@ public class CapConfig { private String hostname = "localhost"; private String androidScheme = CAPACITOR_HTTPS_SCHEME; private String[] allowNavigation; + private boolean routeWithFallback = false; // Android Config private String overriddenUserAgentString; @@ -166,6 +167,7 @@ private CapConfig(Builder builder) { } this.allowNavigation = builder.allowNavigation; + this.routeWithFallback = builder.routeWithFallback; // Android Config this.overriddenUserAgentString = builder.overriddenUserAgentString; @@ -252,6 +254,7 @@ private void deserializeConfig(@Nullable Context context) { hostname = JSONUtils.getString(configJSON, "server.hostname", hostname); errorPath = JSONUtils.getString(configJSON, "server.errorPath", null); startPath = JSONUtils.getString(configJSON, "server.appStartPath", null); + routeWithFallback = JSONUtils.getBoolean(configJSON, "server.routeWithFallback", routeWithFallback); String configSchema = JSONUtils.getString(configJSON, "server.androidScheme", androidScheme); if (this.validateScheme(configSchema)) { @@ -349,6 +352,10 @@ public String getHostname() { return hostname; } + public boolean isRouteWithFallback() { + return routeWithFallback; + } + public String getStartPath() { return startPath; } @@ -574,6 +581,7 @@ public static class Builder { private String hostname = "localhost"; private String androidScheme = CAPACITOR_HTTPS_SCHEME; private String[] allowNavigation; + private boolean routeWithFallback = false; // Android Config Values private String overriddenUserAgentString; @@ -644,6 +652,11 @@ public Builder setHostname(String hostname) { return this; } + public Builder setRouteWithFallback(boolean routeWithFallback) { + this.routeWithFallback = routeWithFallback; + return this; + } + public Builder setStartPath(String path) { this.startPath = path; return this; diff --git a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java index a044bfbe6..d627598a6 100755 --- a/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java +++ b/android/capacitor/src/main/java/com/getcapacitor/WebViewLocalServer.java @@ -652,8 +652,20 @@ public InputStream handle(Uri url) { stream = protocolHandler.openAsset(assetPath + path); } } catch (IOException e) { - Logger.error("Unable to open asset URL: " + url); - return null; + // If routeWithFallback is enabled and the path has an extension (contains a dot) + // fallback to index.html for SPA routing + if (bridge.getConfig().isRouteWithFallback() && path.contains(".")) { + try { + String indexPath = isAsset ? assetPath + "/index.html" : basePath + "/index.html"; + stream = isAsset ? protocolHandler.openAsset(indexPath) : protocolHandler.openFile(indexPath); + } catch (IOException indexException) { + Logger.error("Unable to open asset URL: " + url); + return null; + } + } else { + Logger.error("Unable to open asset URL: " + url); + return null; + } } return stream; diff --git a/cli/src/declarations.ts b/cli/src/declarations.ts index db8fe033f..38dbc8641 100644 --- a/cli/src/declarations.ts +++ b/cli/src/declarations.ts @@ -619,6 +619,17 @@ export interface CapacitorConfig { * @default null */ appStartPath?: string; + + /** + * Enable fallback to index.html for SPA routes with dots. + * When true, if a requested path contains a dot but the file doesn't exist, + * the server will serve index.html instead. This allows SPA routes like + * /@user.name or /file.json to work correctly when they're not actual files. + * + * @since 7.5.0 + * @default false + */ + routeWithFallback?: boolean; }; cordova?: { diff --git a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift index 460af412c..68054604e 100644 --- a/ios/Capacitor/Capacitor/CAPBridgeViewController.swift +++ b/ios/Capacitor/Capacitor/CAPBridgeViewController.swift @@ -53,6 +53,7 @@ import Cordova let assetHandler = WebViewAssetHandler(router: router()) assetHandler.setAssetPath(configuration.appLocation.path) assetHandler.setServerUrl(configuration.serverURL) + assetHandler.setRouteWithFallback(configuration.routeWithFallback) let delegationHandler = WebViewDelegationHandler() prepareWebView(with: configuration, assetHandler: assetHandler, delegationHandler: delegationHandler) view = webView diff --git a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h index 171dbb463..49c9551b7 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h +++ b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.h @@ -26,6 +26,7 @@ NS_SWIFT_NAME(InstanceConfiguration) @property (nonatomic, readonly) UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior; @property (nonatomic, readonly, nonnull) NSURL *appLocation; @property (nonatomic, readonly, nullable) NSString *appStartPath; +@property (nonatomic, readonly) BOOL routeWithFallback; @property (nonatomic, readonly) BOOL limitsNavigationsToAppBoundDomains; @property (nonatomic, readonly, nullable) NSString *preferredContentMode; diff --git a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m index d6b7785e3..30c9c46f3 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m +++ b/ios/Capacitor/Capacitor/CAPInstanceConfiguration.m @@ -35,6 +35,7 @@ - (instancetype)initWithDescriptor:(CAPInstanceDescriptor *)descriptor isDebug:( _contentInsetAdjustmentBehavior = descriptor.contentInsetAdjustmentBehavior; _appLocation = descriptor.appLocation; _appStartPath = descriptor.appStartPath; + _routeWithFallback = descriptor.routeWithFallback; _limitsNavigationsToAppBoundDomains = descriptor.limitsNavigationsToAppBoundDomains; _preferredContentMode = descriptor.preferredContentMode; _pluginConfigurations = descriptor.pluginConfigurations; @@ -81,6 +82,7 @@ - (instancetype)initWithConfiguration:(CAPInstanceConfiguration*)configuration a _legacyConfig = [[configuration legacyConfig] copy]; #pragma clang diagnostic pop _appStartPath = configuration.appStartPath; + _routeWithFallback = configuration.routeWithFallback; _appLocation = [location copy]; } return self; diff --git a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h index f477ef55b..f43d15bfb 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h +++ b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.h @@ -124,6 +124,11 @@ NS_SWIFT_NAME(InstanceDescriptor) @discussion Defaults to nil, in which case Capacitor will attempt to load @c index.html. */ @property (nonatomic, copy, nullable) NSString *appStartPath; +/** + @brief Whether to fallback to index.html for SPA routes with dots when file doesn't exist. + @discussion Defaults to @c false. Set by @c server.routeWithFallback in the configuration file. + */ +@property (nonatomic, assign) BOOL routeWithFallback; /** @brief Whether or not the Capacitor WebView will limit the navigation to @c WKAppBoundDomains listed in the Info.plist. @discussion Defaults to @c false. Set by @c ios.limitsNavigationsToAppBoundDomains in the configuration file. Required to be @c true for plugins to work if the app includes @c WKAppBoundDomains in the Info.plist. diff --git a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift index a7eff5f62..69d2387d4 100644 --- a/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift +++ b/ios/Capacitor/Capacitor/CAPInstanceDescriptor.swift @@ -152,6 +152,9 @@ internal extension InstanceDescriptor { if let startPath = (config[keyPath: "server.appStartPath"] as? String) { appStartPath = startPath } + if let fallback = config[keyPath: "server.routeWithFallback"] as? Bool { + routeWithFallback = fallback + } } } // swiftlint:enable cyclomatic_complexity diff --git a/ios/Capacitor/Capacitor/Router.swift b/ios/Capacitor/Capacitor/Router.swift index 68328f8cd..c4ca78e78 100644 --- a/ios/Capacitor/Capacitor/Router.swift +++ b/ios/Capacitor/Capacitor/Router.swift @@ -9,20 +9,29 @@ import Foundation public protocol Router { - func route(for path: String) -> String + func route(for path: String, checkFileExists: Bool) -> String var basePath: String { get set } } public struct CapacitorRouter: Router { public init() {} public var basePath: String = "" - public func route(for path: String) -> String { + + public func route(for path: String, checkFileExists: Bool = false) -> String { let pathUrl = URL(fileURLWithPath: path) // If there's no path extension it also means the path is empty or a SPA route if pathUrl.pathExtension.isEmpty { return basePath + "/index.html" } + + // If checkFileExists is enabled and file doesn't exist, fallback to index.html + if checkFileExists { + let fullPath = basePath + path + if !FileManager.default.fileExists(atPath: fullPath) { + return basePath + "/index.html" + } + } return basePath + path } diff --git a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift index 457115248..3bba42c7e 100644 --- a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift @@ -6,6 +6,7 @@ import MobileCoreServices open class WebViewAssetHandler: NSObject, WKURLSchemeHandler { private var router: Router private var serverUrl: URL? + private var routeWithFallback: Bool = false public init(router: Router) { self.router = router @@ -19,6 +20,10 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler { open func setServerUrl(_ serverUrl: URL?) { self.serverUrl = serverUrl } + + open func setRouteWithFallback(_ routeWithFallback: Bool) { + self.routeWithFallback = routeWithFallback + } private func isUsingLiveReload(_ localUrl: URL) -> Bool { return self.serverUrl != nil && self.serverUrl?.scheme != localUrl.scheme @@ -38,7 +43,7 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler { if stringToLoad.starts(with: CapacitorBridge.fileStartIdentifier) { startPath = stringToLoad.replacingOccurrences(of: CapacitorBridge.fileStartIdentifier, with: "") } else { - startPath = router.route(for: stringToLoad) + startPath = router.route(for: stringToLoad, checkFileExists: routeWithFallback) } let fileUrl = URL.init(fileURLWithPath: startPath) diff --git a/ios/Capacitor/CapacitorTests/RouterTests.swift b/ios/Capacitor/CapacitorTests/RouterTests.swift index 181551ee7..12f0dc697 100644 --- a/ios/Capacitor/CapacitorTests/RouterTests.swift +++ b/ios/Capacitor/CapacitorTests/RouterTests.swift @@ -27,12 +27,27 @@ class RouterTests: XCTestCase { checkRouter(path: "/a/valid/file path.ext", expected: "/a/valid/file path.ext") } + func testRouterWithFallbackReturnsIndexWhenFileDoesNotExist() { + XCTContext.runActivity(named: "router with fallback returns index.html for non-existent files") { _ in + var router = CapacitorRouter() + router.basePath = "/NonExistentPath" + + // When checkFileExists is true and file doesn't exist, should return index.html + XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: true), "/NonExistentPath/index.html") + XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: true), "/NonExistentPath/index.html") + + // When checkFileExists is false, should return the path as-is + XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: false), "/NonExistentPath/@user.name") + XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: false), "/NonExistentPath/api/data.json") + } + } + func checkRouter(path: String, expected: String) { XCTContext.runActivity(named: "router creates route path correctly") { _ in var router = CapacitorRouter() - XCTAssertEqual(router.route(for: path), expected) + XCTAssertEqual(router.route(for: path, checkFileExists: false), expected) router.basePath = "/A/Route" - XCTAssertEqual(router.route(for: path), "/A/Route" + expected) + XCTAssertEqual(router.route(for: path, checkFileExists: false), "/A/Route" + expected) } } From 8307435a0d8e20fb77022936c56704032cc08951 Mon Sep 17 00:00:00 2001 From: Riajul Islam Date: Tue, 2 Sep 2025 08:17:32 +0000 Subject: [PATCH 2/2] fix(ios): handle SPA fallback in error handler instead of router The previous approach of checking file existence in the Router was causing frame load interruptions. This moves the fallback logic to the WebViewAssetHandler's error handling, similar to the Android implementation. When a file with an extension fails to load and routeWithFallback is enabled, we catch the error and serve index.html instead, allowing SPA routes to work properly. Fixes iOS routing issues for URLs with dots like /@user.name --- ios/Capacitor/Capacitor/Router.swift | 12 ++-------- .../Capacitor/WebViewAssetHandler.swift | 23 ++++++++++++++++++- .../CapacitorTests/RouterTests.swift | 19 ++------------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/ios/Capacitor/Capacitor/Router.swift b/ios/Capacitor/Capacitor/Router.swift index c4ca78e78..9550ea6a9 100644 --- a/ios/Capacitor/Capacitor/Router.swift +++ b/ios/Capacitor/Capacitor/Router.swift @@ -9,7 +9,7 @@ import Foundation public protocol Router { - func route(for path: String, checkFileExists: Bool) -> String + func route(for path: String) -> String var basePath: String { get set } } @@ -17,21 +17,13 @@ public struct CapacitorRouter: Router { public init() {} public var basePath: String = "" - public func route(for path: String, checkFileExists: Bool = false) -> String { + public func route(for path: String) -> String { let pathUrl = URL(fileURLWithPath: path) // If there's no path extension it also means the path is empty or a SPA route if pathUrl.pathExtension.isEmpty { return basePath + "/index.html" } - - // If checkFileExists is enabled and file doesn't exist, fallback to index.html - if checkFileExists { - let fullPath = basePath + path - if !FileManager.default.fileExists(atPath: fullPath) { - return basePath + "/index.html" - } - } return basePath + path } diff --git a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift index 3bba42c7e..dd3de4ae0 100644 --- a/ios/Capacitor/Capacitor/WebViewAssetHandler.swift +++ b/ios/Capacitor/Capacitor/WebViewAssetHandler.swift @@ -43,7 +43,7 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler { if stringToLoad.starts(with: CapacitorBridge.fileStartIdentifier) { startPath = stringToLoad.replacingOccurrences(of: CapacitorBridge.fileStartIdentifier, with: "") } else { - startPath = router.route(for: stringToLoad, checkFileExists: routeWithFallback) + startPath = router.route(for: stringToLoad) } let fileUrl = URL.init(fileURLWithPath: startPath) @@ -99,6 +99,27 @@ open class WebViewAssetHandler: NSObject, WKURLSchemeHandler { } urlSchemeTask.didReceive(data) } catch let error as NSError { + // If routeWithFallback is enabled and the original request was for a file with an extension, + // try to serve index.html as a fallback for SPA routing + if routeWithFallback && stringToLoad.contains(".") && !stringToLoad.hasSuffix("/index.html") { + do { + let indexPath = router.basePath + "/index.html" + let indexUrl = URL(fileURLWithPath: indexPath) + let indexData = try Data(contentsOf: indexUrl) + let indexMimeType = "text/html" + let indexHeaders = [ + "Content-Type": indexMimeType, + "Cache-Control": "no-cache" + ] + let indexResponse = HTTPURLResponse(url: localUrl, statusCode: 200, httpVersion: nil, headerFields: indexHeaders) + urlSchemeTask.didReceive(indexResponse!) + urlSchemeTask.didReceive(indexData) + urlSchemeTask.didFinish() + return + } catch { + // If index.html also fails, fall through to original error + } + } urlSchemeTask.didFailWithError(error) return } diff --git a/ios/Capacitor/CapacitorTests/RouterTests.swift b/ios/Capacitor/CapacitorTests/RouterTests.swift index 12f0dc697..181551ee7 100644 --- a/ios/Capacitor/CapacitorTests/RouterTests.swift +++ b/ios/Capacitor/CapacitorTests/RouterTests.swift @@ -27,27 +27,12 @@ class RouterTests: XCTestCase { checkRouter(path: "/a/valid/file path.ext", expected: "/a/valid/file path.ext") } - func testRouterWithFallbackReturnsIndexWhenFileDoesNotExist() { - XCTContext.runActivity(named: "router with fallback returns index.html for non-existent files") { _ in - var router = CapacitorRouter() - router.basePath = "/NonExistentPath" - - // When checkFileExists is true and file doesn't exist, should return index.html - XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: true), "/NonExistentPath/index.html") - XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: true), "/NonExistentPath/index.html") - - // When checkFileExists is false, should return the path as-is - XCTAssertEqual(router.route(for: "/@user.name", checkFileExists: false), "/NonExistentPath/@user.name") - XCTAssertEqual(router.route(for: "/api/data.json", checkFileExists: false), "/NonExistentPath/api/data.json") - } - } - func checkRouter(path: String, expected: String) { XCTContext.runActivity(named: "router creates route path correctly") { _ in var router = CapacitorRouter() - XCTAssertEqual(router.route(for: path, checkFileExists: false), expected) + XCTAssertEqual(router.route(for: path), expected) router.basePath = "/A/Route" - XCTAssertEqual(router.route(for: path, checkFileExists: false), "/A/Route" + expected) + XCTAssertEqual(router.route(for: path), "/A/Route" + expected) } }