Besides dealing with challenges of Apple Watch development, we needed a solution for reaching the development server we are working on.
When you are developing a full stack Swift application, you want to easily test and debug your application on both the device (iPhone, Apple Watch, iPad, etc...) as well as your development server. If you are using simulator then setting your host server to localhost
will work but often we need to test on an actual device.
This is especially true when it comes to developing on for the Apple Watch. There's no easy input control for the developer change the server address.
So I ended up creating a Swift Package to enable automatic discovery of your local development server on the fly called Sublimation. It turns your Vapor server from a mysterious gas to a tangible solid server to connect to.
The purpose is to optimize developer experience and remove as much need to be an IT expert or tinking with the development environment.
For the server and client we need a way to communicate that information without the client knowing where the server is initially.
flowchart TD
%% Nodes for devices with Font Awesome icons
subgraph Devices
iPhone("fa:fa-mobile-alt iPhone")
Watch("fa:fa-square Apple Watch")
iPad("fa:fa-tablet-alt iPad")
VisionPro("fa:fa-vr-cardboard Vision Pro")
end
%% Node for Sublimation service with Font Awesome package icon
Sublimation("fa:fa-box Sublimation")
%% Node for API server with Font Awesome icon
Server("fa:fa-server API Server")
%% Edge connections
Devices <--> Sublimation
Sublimation <--> Server
There's two ways to do this - have a consistent location for fetching the address or a way to discover the service on the network.
The initial approach was using ngrok
to create an a public host name and entering that info in the app's environment variables insert picture
. This worked but required the developer to update the environment each time ngrok was restarted. If there was a way to save and fetch the new host name consistently for the developer that would reduce one more step.
The missing piece was get the ngrok url created and storing in the cloud somehow via a fairly simple method.
sequenceDiagram
participant DevServer as Development Server
participant Sub as Sublimation (Server)
participant Ngrok as Ngrok (https://ngrok.com)
participant KVdb as KVdb (https://kvdb.io)
participant SubClient as Sublimation (Client)
participant App as iOS/watchOS App
DevServer->>Sub: Start development server
Sub->>Ngrok: Request public URL
Ngrok-->>Sub: Provide public URL<br/>(https://abc123.ngrok.io)
Sub->>KVdb: Store URL with bucket and key<br/>(bucket: "fdjf9012k20cv", key: "dev-server",<br/>url: https://abc123.ngrok.io)
App->>SubClient: Request server URL<br/>(bucket: "fdjf9012k20cv", key: "dev-server")
SubClient->>KVdb: Request URL<br/>(bucket: "fdjf9012k20cv", key: "dev-server")
KVdb-->>SubClient: Provide stored URL<br/>(https://abc123.ngrok.io)
SubClient-->>App: Return server URL<br/>(https://abc123.ngrok.io)
App->>Ngrok: Connect to development server<br/>(https://abc123.ngrok.io)
Ngrok->>DevServer: Forward request to local server
What I found was that we can run ngrok
on startup and access the newly created url via the local API. To store it in the cloud I found a service called kvdb.io which only required a bucket name and key for storing the public url.
If you haven't already setup an account with ngrok and install the command-line tool via homebrew. Next let's setup a key-value storage with kvdb.io which is currently supported. If you have another service, please create an issue in the repo. Your feedback is helpful.
Sign up at kvdb.io and get a bucket name you'll use. You'll be using that for your setup. Essentially there are three components you'll need:
- ngrok executable path
- if you installed via homebrew it's
/opt/homebrew/bin/ngrok
but you can find out using:which ngrok
after installation
- if you installed via homebrew it's
- bucket name from your kvdb.io
- key from your kvdb.io
- you just need to pick something unique for your server and client to use
Save these somewhere in your shared configuration for both your server and client to access, such as an enum
:
public enum SublimationConfiguration {
public static let bucketName = "fdjf9012k20cv"
public static let key = "my-"
}
When creating your Sublimation
object you'll want to use the provided convience initializers SublimationTunnel/TunnelSublimatory/init(ngrokPath:bucketName:key:application:isConnectionRefused:ngrokClient:)
to make it easier for ngrok integration with the SublimationTunnel/TunnelSublimatory
:
let tunnelSublimatory = TunnelSublimatory(
ngrokPath: "/opt/homebrew/bin/ngrok", //
bucketName: SublimationConfiguration.bucketName, // "fdjf9012k20cv"
key: SublimationConfiguration.key, // "dev-server"
application: { myVaporApplication }, // pass your Vapor.Application here
isConnectionRefused: {$.isConnectionRefused}, // supplied by `SublimationVapor`
transport: AsyncHTTPClientTransport() // ClientTransport for Vapor
)
let sublimation = Sublimation(sublimatory: tunnelSublimatory)
For the client, you'll need to import the SublimationKVdb
module and retrieve the url via:
import SublimationKVdb
let hostURL = try await KVdb.url(withKey: key, atBucket: bucketName)
- difficult setup with installing ngrok and setting up configuration for each developer
- ngrok maybe already running?
The ultimate goal is something which requires no configuration almost 0 configuration. This is where Bonjour comes in.
what is Bonjour
sequenceDiagram
participant Server as Hummingbird/Vapor Server
participant BonjourSub as BonjourSublimatory
participant NWListener as NWListener
participant Network as Local Network
participant BonjourClient as BonjourClient
participant App as iOS/watchOS App
Server->>BonjourSub: Start server, provide IP addresses,<br/>hostnames, port, and protocol (http/https)
BonjourSub->>NWListener: Configure with server information
NWListener->>Network: Advertise service:<br/>1. Send encoded server data<br/>2. Use Text Record for additional info
App->>BonjourClient: Request server URL
BonjourClient->>Network: Search for advertised services
Network-->>BonjourClient: Return advertised service information
BonjourClient->>BonjourClient: 1. Receive and decode server data<br/>2. Parse Text Record
BonjourClient-->>App: Return AsyncStream<URL><br/>or first available URL
App->>Server: Connect to server using discovered URL
When the Hummingbird or Vapor server begins it will tell Sublimation the ip addresses or host names which are available to access the server from (including the port number and whether to use https or http). This is called a BonjourSublimatory
. The BonjourSublimatory
then uses NWListener
to advertise this information both by send the data encoded using Protocol Buffers as well as inside the Text Record advertised.
The iPhone or Apple Watch then uses a BonjourClient
to fetch either an AsyncStream
of URL
or simply get the first
one available.
Create a BindingConfiguration
with:
- a list of host names and ip address
- port number of the server
- whether the server uses https or http
let bindingConfiguration = BindingConfiguration(
host: ["Leo's-Mac.local", "192.168.1.10"],
port: 8080
isSecure: false
)
Create a BonjourSublimatory
using that BindingConfiguration
and include your server's logger. Then attach it to the Sublimation
object:
let bonjour = BonjourSublimatory(
bindingConfiguration: bindingConfiguration,
logger: app.logger
)
let sublimation = Sublimation(sublimatory : bonjour)
On the device, create a BonjourClient
and either get an AsyncStream
of URL
objects or just ask for the first one:
let client = BonjourClient(logger: app.logger)
let hostURL = await client.first()
- no external dependencies
- minimal configuration
Lastly Sublimation
can be used in Server Side Swift either via a Vapor LifecycleHandler
or Lifecycle Service
.
If you are Hummingbird, you can just add it as a service:
let sublimation = Sublimation(
bindingConfiguration: .init(hosts: hosts, configuration: configuration.hosting)
)
var app = Application(
router: router,
server: .http1WebSocketUpgrade(webSocketRouter: wsRouter),
configuration: .init(address: .init(setup: configuration.hosting))
)
app.addServices(sublimation)
For Vapor
, you'd add it to the lifecycle of the app:
let sublimation = Sublimation(
bindingConfiguration: .init(hosts: hosts, configuration: configuration.hosting)
)
var app : Application
app.lifecycle.use(sublimation)