Building Capacitor mobile App from Meteor+React

Hello all,

TL;DR:

After a year of working with @victor at Allohouston on building apps with Meteor, we’ve been working more recently on integrating CapacitorJS into a Meteor + React project that needed a native app to enable background geolocation. This post shares our path trying to generate a mobile app with Cordova first, then Capacitor; the challenges faced (notably with @tanstack/react-query@5and Meteor’s “modern” client detection), and the workarounds we used to get a nearly fully working iOS build with Capacitor.
Android has not been tested yet.

Context

We’re building a mobile-enabled Meteor + React app that enables route planning based on dynamic local road traffic restrictions. We needed native features like background geolocation, and began exploring mobile wrappers: Cordova and Capacitor.

While our main goal was to get our own app working, we’re also sharing this here to document the path, surface current limitations, and hopefully pave the way for future integration between Meteor and Capacitor.

We’re hoping this post can:

Attempt 1

We first tried building a native app with Cordova following the Meteor documentation. The setup worked, but the app launched to a white screen with the following error: (privateGet.values is not a function).

We traced the issue to @tanstack/react-query@5, which wasn’t being compiled properly. Downgrading to v4 resolved it, but downgrading was not an option for us nor was changing library, due to other constraints.

Yet we found no way of making it work within Cordova environment. We thought of raising an issue about it, but ultimately decided to explore Capacitor instead, especially after seeing indications that Meteor’s future may lean toward Capacitor.

:construction: Attempt 2 – Capacitor

Capacitor requires a static index.html to work, but Meteor streams HTML at runtime using the boilerplate-generatorpackage — no actual file exists on disk. Cordova’s build does generate an index.html, so we copied that into web.browser, removed all “cordova” occurrences, and launched Capacitor.

That worked to launch the app, but Meteor wasn’t initializing properly. The key issue was incorrect/missing properties in __meteor_runtime_config__, specifically regarding HRM versions, and settings.

We manually merged the missing properties from a working web session, keeping ROOT_URL , ROOT_URL_PATH_PREFIX , DDP_DEFAULT_CONNECTION_URL, needed by the remote client to tell were the actual meteor server is. Once added, the Meteor client ran normally — until…

Modern vs Legacy Client Detection

We were again blocked by the original white screen issue caused by @tanstack/react-query@5.

After debugging Meteor’s build process, we discovered the iOS WebView user agent was seen as: mobileSafari 0.0.0

Meteor marked the client as legacy, and the modern bundle (with correct polyfills and transpilation) wasn’t loaded.

To fix this, we overrode the WebView user agent in iOS to include a valid version string. You can also use appendUserAgent or overrideUserAgent in the Capacitor config, but that applies globally — so we took a more granular approach and dynamically injected the iOS version based on device properties in the native code of Capacitor.

Here is the code injected in the `AppDelegate.swift` file
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    		// Override point for customization after application launch.
    		setCustomUserAgent()
    		// [capacitor-background-fetch]
    		
        return true
    }
    
    private func setCustomUserAgent() {
    		// Wait for WebView to initialize
    		DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 
    		    if let webView = self.findWebView() {
            // Construct the User Agent manually
            // Format iOS version
    		        let iosVersion = UIDevice.current.systemVersion
    		        // Device model (e.g., iPhone)
                let model = UIDevice.current.model
                // Example build number (this can be dynamic if needed)
                let buildNumber = "15E148"
                let newUserAgent = "Mozilla/5.0 (\(model); CPU \(model) OS \(iosVersion.replacingOccurrences(of: ".", with: "_")) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/\(buildNumber) Version/\(iosVersion)"
                    
                 webView.customUserAgent = newUserAgent
            }
        }
    }
        
    private func findWebView() -> WKWebView? {
        guard let keyWindow = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .flatMap({ $0.windows })
            .first(where: { $0.isKeyWindow }),
              let rootVC = keyWindow.rootViewController as? CAPBridgeViewController else {
            return nil
        }
        return rootVC.webView
    }

Assets loading & HMR

At this point, our Meteor app was running in Capacitor on iOS. A few remaining issues:

  • Static assets loading:
    • cordova-plugin-meteor-webapp uses program.json as a manifest to load assets and handle hot-code push.
    • Capacitor doesn’t support this, so we added helpers directly in the iOS native code to recover assets from that program.json file. We did not look into hot-code push.
Here is the code injected in the `WebViewAssetHandler.swift` file
open func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    let startPath: String
    let url = urlSchemeTask.request.url!
    var stringToLoad = url.path
    let localUrl = URL.init(string: url.absoluteString)!
  
    if url.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
        handleCapacitorHttpRequest(urlSchemeTask, localUrl, false)
        return
    }
  
    if stringToLoad.starts(with: CapacitorBridge.fileStartIdentifier) {
        startPath = stringToLoad.replacingOccurrences(of: CapacitorBridge.fileStartIdentifier, with: "")
    } else {
        // Look up correct path from program.json
        if let resolvedPath = resolveAssetPath(stringToLoad) {
            stringToLoad = "/" + resolvedPath
        }
        
        startPath = router.route(for: stringToLoad)
    }
  
    let fileUrl = URL.init(fileURLWithPath: startPath)
  
				//capacitorJs part
    do {
        var data = Data()
        let mimeType = mimeTypeForExtension(pathExtension: url.pathExtension)
        var headers =  [
            "Content-Type": mimeType,
            "Cache-Control": "no-cache"
        ]
  
        // if using live reload, then set CORS headers
        if isUsingLiveReload(localUrl) {
            headers["Access-Control-Allow-Origin"] = self.serverUrl?.absoluteString
            headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS, TRACE"
        }
  
        if let rangeString = urlSchemeTask.request.value(forHTTPHeaderField: "Range"),
           let totalSize = try fileUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize,
           isMediaExtension(pathExtension: url.pathExtension) {
            let fileHandle = try FileHandle(forReadingFrom: fileUrl)
            let parts = rangeString.components(separatedBy: "=")
            let streamParts = parts[1].components(separatedBy: "-")
            let fromRange = Int(streamParts[0]) ?? 0
            var toRange = totalSize - 1
            if streamParts.count > 1 {
                toRange = Int(streamParts[1]) ?? toRange
            }
            let rangeLength = toRange - fromRange + 1
            try fileHandle.seek(toOffset: UInt64(fromRange))
            data = fileHandle.readData(ofLength: rangeLength)
            headers["Accept-Ranges"] = "bytes"
            headers["Content-Range"] = "bytes \(fromRange)-\(toRange)/\(totalSize)"
            headers["Content-Length"] = String(data.count)
            let response = HTTPURLResponse(url: localUrl, statusCode: 206, httpVersion: nil, headerFields: headers)
            urlSchemeTask.didReceive(response!)
            try fileHandle.close()
        } else {
            if !stringToLoad.contains("cordova.js") {
                if isMediaExtension(pathExtension: url.pathExtension) {
                    data = try Data(contentsOf: fileUrl, options: Data.ReadingOptions.mappedIfSafe)
                } else {
                    data = try Data(contentsOf: fileUrl)
                }
            }
            let urlResponse = URLResponse(url: localUrl, mimeType: mimeType, expectedContentLength: data.count, textEncodingName: nil)
            let httpResponse = HTTPURLResponse(url: localUrl, statusCode: 200, httpVersion: nil, headerFields: headers)
            if isMediaExtension(pathExtension: url.pathExtension) {
                urlSchemeTask.didReceive(urlResponse)
            } else {
                urlSchemeTask.didReceive(httpResponse!)
            }
        }
        urlSchemeTask.didReceive(data)
    } catch let error as NSError {
        urlSchemeTask.didFailWithError(error)
        return
    }
    urlSchemeTask.didFinish()
}    
  
// Load program.json (this should be done once and cached)
func loadProgramJson() -> [String: Any]? {
    if let path = Bundle.main.path(forResource: "public/program", ofType: "json") {
        do {
            let data = try Data(contentsOf: URL(fileURLWithPath: path))
            let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
            return json
        } catch {
            CAPLog.print("Error loading program.json: \(error)")
        }
    }
  
    return nil
}
  
// Function to resolve asset path
func resolveAssetPath(_ requestPath: String) -> String? {
    guard let programJson = loadProgramJson(),
          let assets = programJson["manifest"] as? [[String: Any]] else {
        return nil
    }
    for asset in assets {
        if let url = asset["url"] as? String, requestPath == url {
            return asset["path"] as? String
        }
    }
    return nil
}
  • Hot code reload:
    • During development, updating Meteor erases the copied index.html (generated from Cordova build and that we needed) and changes HMR-related values.
    • We wrote a post-build sync script to re-copy files and patch things back after each change. This is indeed a hack and should be handled by Meteor build tool when integrating with capacitor.

What’s left ?

The investigation was mainly done on iOS. We did not investigate assets loading in Android. We’re also unsure about the modern issued in Android.

Also, we are not sure yet how different it would be with a production build. Deploying a minor bugfix in production should probably not force you to release a new mobile version of your app. Theoretically, Live update should allow to update the app without a new release.

What We’d Love Feedback On

  • Is there a more idiomatic way within the Meteor build pipeline to expose __meteor_runtime_config__ to a Capacitor wrapper?
  • Are there internal hooks or config options in the boilerplate-generator or another packages that could make generating a standalone index.html more stable or automated?
  • Has anyone looked into alternatives to cordova-plugin-meteor-webapp for asset serving and HCP that could work with Capacitor?
  • More generally — if you know the Meteor build/runtime internals, we’d love your take on how this kind of hybrid setup could be better supported.

We’re happy to provide more detail on our patches if it can help move official support forward. Let us know what you think or if there are better approaches!

3 Likes

This is amazing and a huge step forward. Though, given the swift code you shared. I’m led to believe this was only possible due to your deep IOS/Swift knowledge.

@nachocodoner is already running Capacitor in a hobby project of his, so he’d definitely be interested.

I’ve been toying with the idea of wrapping a Meteor+React app in Capacitor for similar reasons, but didn’t expect so many quirks with the build process and client detection. The meteor runtime config patching makes sense now that you mention it, and I wouldn’t have thought to check the iOS user agent behavior.