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@5
and 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:
- help other devs hitting similar issues,
- provide context to contributors working on Capacitor support in Meteor (see the trending discussion Replace Cordova with Capacitor (or similar) · meteor/meteor · Discussion #13562 · GitHub),
- and spark discussion with anyone familiar with the Meteor build process and asset pipeline.
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.
Attempt 2 – Capacitor
Capacitor requires a static index.html
to work, but Meteor streams HTML at runtime using the boilerplate-generator
package — 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
usesprogram.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.
- During development, updating Meteor erases the copied
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 standaloneindex.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!