Running a Web Server on iOS with Vapor

With Vapor, your iOS app can be both the client and the server to control your data — or even other devices. This tutorial will show you how to get started with client-server communication in the same process. By Beau Nouvelle.

5 (3) · 2 Reviews

Download materials
Save for later
Share

With Vapor, your iOS app can be both the client and the server to control your data — or even other devices. This tutorial will show you how to get started with client-server communication in the same process.

Most of the world’s websites run on machines in large server farms. If you’re an enthusiast, you might even have a few servers around your home to control things like your 3D printer or as file storage for your home theater.

In this tutorial, you’re going to break the rules and turn your mobile phone into a mobile server. The device in your pocket wasn’t designed for such a task, but when has that stopped you?

You’ll learn how to:

  • Integrate a Vapor server into your iOS Apps.
  • Connect to your iOS app from anywhere on the internet.
  • Update the state and contents of your app through a web browser.

By the end of this tutorial, you’ll have an iOS app that can host files and serve them up to anyone in the world!

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

You’re going to focus on integrating server-side code into an iOS app. The starter project comes with the required HTML for running the website you’ll be serving and some code for previewing files once uploaded.

Open the starter project in Xcode, take a look through the provided files and give it a run to make sure everything compiles.

Setting Up Your App’s Server

This project will use only two Swift Packages: Vapor and Leaf. The core Vapor package has everything you need for building the backend to your server, and the Leaf package allows you to create a webpage for the frontend.

After opening Xcode, SwiftPM should resolve and download the latest versions of these packages for you.

Examining Your Server

Before going any further, have a look at the structure of the project. When building multiplatform applications, developers generally divide a project into different directories based on the platforms they’re targeting.

Even though this project has a single target, the iOS frontend and server backend are separate. This is a great approach to take because it keeps the project tidy, and it reduces cognitive load during development by allowing you to focus on one area at a time.

Create a new file inside the server folder, name it FileServer.swift and add the following code:

import Leaf
import Vapor

class FileServer: ObservableObject {
  // 1
  var app: Application
  // 2
  let port: Int
  
  init(port: Int) {
    self.port = port
    // 3
    app = Application(.development)
    // 4
    configure(app)
  }
}

You’ll need more code before compiling, but here’s what the code above does:

  1. This handles the full lifecycle and configuration of the server. Everything you do on the server runs through Application.
  2. The port your server runs on. Generally, web servers run on port 80 or 443. However, these are reserved on iOS, so you’ll specify a different one later.
  3. This gives you more debug information during development. Be sure to change this to .production when you’re ready to ship!
  4. You’ll set this up next.

Configuring Your Server

Below the initializer, add a configure function.

private func configure(_ app: Application) {
  // 1
  app.http.server.configuration.hostname = "0.0.0.0"
  app.http.server.configuration.port = port

  // 2
  app.views.use(.leaf)
  // 3
  app.leaf.cache.isEnabled = app.environment.isRelease
  // 4
  app.leaf.configuration.rootDirectory = Bundle.main.bundlePath
  // 5
  app.routes.defaultMaxBodySize = "50MB"
}

Here’s what’s happening:

  1. Specifies how others can reach your server.
  2. Informs Vapor that you’ll be using Leaf as this project’s web rendering engine.
  3. Disabling web caching ensures you get the latest version of the webpage you’re working on any time you refresh it. This allows you to see your changes immediately. For production builds, caching can help improve the performance of your website.
  4. This setting tells Leaf where to find your .leaf templates, images, fonts, and other code for your server’s frontend.
  5. Vapor has a default file upload size of 16 KB. That’s far too small for the file server you’re building. Here, it’s set to 50 MB, but you can increase it if you plan to support larger files.

You must set the server’s hostname to “0.0.0.0” because this allows any other device on the same network to access your server. This network could be the one provided by your home router, or by the device itself — like an ad hoc configuration via a personal hotspot.

This also ensures the router can direct requests from outside the network to your device, allowing for connections over the internet.

Starting the Server

Before you can test the server, you’ll need a way to start it.

Below the configure function, add a new one and name it start().

func start() {
  // 1
  Task(priority: .background) {
    do {
      // 2
      try app.start()
    } catch {
      fatalError(error.localizedDescription)
    }
  }
}

There are two important things happening here:

  1. First, you run the server on a background thread.
  2. Then, start the server at 0.0.0.0 and the specified port.
Note: Generally, when building a Server-Side Swift application you’ll want to run it on the Main Thread. But, this project also has an iOS component, and it’s essential the main thread is free to process user interactions. Otherwise, the iOS app may see a significant performance drop with stuttering frames any time the server is doing work.

Running the server at a lower priority ensures that you have the smoothest possible experience on both systems.

Open ContentView.swift, initialize the server and call server.start() within .onAppear.

struct ContentView: View {
  @StateObject var server = FileServer(port: 8080)

  var body: some View {
    Text("Hello, World!")
      .onAppear {
        server.start()
      }
  }
}

Build and run your app. You’ll see “Hello, World!” in the middle of the simulator, but if you take a look at the console, you’ll see:

Server starting on http://0.0.0.0:8080

Navigate to localhost:8080 in a web browser on the same computer, and you’ll see the following error message:

{
   "error": true,
   "reason": "Not Found"
}

This is a response from the server informing you that it couldn’t find anything to serve you at that path.

This is expected because you haven’t created the routes yet. A great success nonetheless!

Creating Routes

In this section, you’ll create four routes to process the incoming requests to your server.

Create a new file in the server folder and name it FileWebRouteCollection.swift.

Start the file by conforming to RouteCollection and adding the boot() protocol method.

import Vapor

struct FileWebRouteCollection: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
  }
}

Application uses this function for route registration.

The first route is filesViewHandler. It’s the entry point to your server and handles any requests sent to "deviceIP":8080.

In fact, this is the only View route your server needs for this project! It will display the entire list of uploaded files, allow users to upload new files and even download them.

func filesViewHandler(_ req: Request) async throws -> View {
  let documentsDirectory = try URL.documentsDirectory()
  let fileUrls = try documentsDirectory.visibleContents()
  let filenames = fileUrls.map { $0.lastPathComponent }
  let context = FileContext(filenames: filenames)
  return try await req.view.render("files", context)
}

This loads the contents of the document’s directory accessible by the iOS app, generates a list of file names and passes them through to the view renderer.

Note: If you import both SwiftUI and Vapor in the same file, you may see compiler errors regarding the View type being returned in filesViewHandler(_:).

To fix this, change the return type to include Vapor as a prefix: Vapor.View.

At the bottom of the file, create the FileContext.

struct FileContext: Encodable {
  var filenames: [String]
}

Take a quick look in server/Views/files.leaf and you’ll see the filenames property on FileContext in use starting at line 24.

...
#for(filename in filenames):
...

This will display all of the filenames to the user in a list, and from there they can download a specific file by clicking on it.

Now, add your new route to the boot function to access it.


routes.get(use: filesViewHandler)

All “get” requests made to the root of your server will now pass through this route.

Open FileServer.swift.

Inside the do statement, before try app.start(), register the FileWebRouteCollection.

try app.register(collection: FileWebRouteCollection())

Build and run.

Open localhost:8080 in your web browser, and you’ll see two buttons.

Choose File and Upload Bootstrap web buttons

You’ll find that the upload button doesn’t work yet because that route doesn’t exist. Time to create it!

Uploading Files

Open FileWebRouteCollection.swift, and at the bottom of the file beneath the FileContext struct, create a new one called FileUploadPostData.

struct FileUploadPostData: Content {
  var file: File
}

Inside FileWebRouteCollection, add a new function to handle file uploads.

func uploadFilePostHandler(_ req: Request) throws -> Response {
  // 1
  let fileData = try req.content.decode(FileUploadPostData.self)
  // 2
  let writeURL = try URL.documentsDirectory().appendingPathComponent(fileData.file.filename)
  // 3
  try Data(fileData.file.data.readableBytesView).write(to: writeURL)
  // 4
  return req.redirect(to: "/")
}

Here's how it works:

  1. Decode the content of the incoming request into a FileUploadPostData object.
  2. Generate a URL based on the documents directory and name of the uploaded file.
  3. Write the file to the generated URL.
  4. If successful, refresh the page by redirecting the browser to the root URL.

Register this new POST route in boot().

routes.post(use: uploadFilePostHandler)

Decoding the web request contents into the FileUploadPostData object isn’t magic.

Open files.leaf and look at the form block.

The first input field is “file” for both name and type. Vapor uses this name parameter to map the file data in the request to the FileUploadPostData.file property.

<form method="POST" action="/" enctype="multipart/form-data">
  <input type="file" name="file">
  <input type="submit" class="btn btn-primary" value="Upload"/>
</form>

Now for the exciting part — build and run.

Navigate to http://localhost:8080 in your web browser and upload a file.

List of uploaded files in a web browser with with buttons to upload more.

Delete doesn’t work yet, but now that you can send files to your iOS device through a web browser, it’s time to create a way to preview them.

Previewing Files

Bundled within the starter project is a struct called FileView. This is a UIViewControllerRepresentable wrapper for QLPreviewController, which is part of Apple's QuickLook framework and is capable of opening a few different file types including images, videos, music and text.

You'll use FileView to preview the uploaded files in the iOS app.

Before you can do that, the iOS side of your project needs to have access to these files. In this guide, you'll be using the FileServer. However, in a much larger project, it would be better to move that responsibility to a dedicated file management object.

Open FileServer.swift and add a new property near the top of the class:

@Published var fileURLs: [URL] = []

This is the single source of truth for all files stored inside the documents directory of your app. SwiftUI listens to any changes in the array of URLs and updates the UI accordingly.

To load these files, create a new function and name it loadFiles()

func loadFiles() {
  do {
    let documentsDirectory = try FileManager.default.url(
      for: .documentDirectory,
      in: .userDomainMask,
      appropriateFor: nil,
      create: false)
    let fileUrls = try FileManager.default.contentsOfDirectory(
      at: documentsDirectory,
      includingPropertiesForKeys: nil,
      options: .skipsHiddenFiles)
    self.fileURLs = fileUrls
  } catch {
    fatalError(error.localizedDescription)
  }
}

This function looks inside the documents directory for your app and returns the URLs for all visible files within. You’ll use this whenever you need to refresh the list of files in the iOS portion of your project.

A great place to call this is in the onAppear method inside ContentView.swift — that way, when your app launches, it will populate the file list with files.

Add it after you start the server:

...
.onAppear {
  server.start()
  server.loadFiles()
}
...

To actually preview the files, you'll also need to replace the Text view with this code:

NavigationView {
  List {
    ForEach(server.fileURLs, id: \.path) { file in
      NavigationLink {
        FileView(url: file)
      } label: {
        Text(file.lastPathComponent)
      }
    }
  }
}

This loops over all the URLs found by the loadFiles() function and creates a navigation link to a FileView for previewing.

Build and run.

You'll see something similar to the following image, depending on which files you uploaded. If you don't see any files, try uploading some through your browser.

You'll need to build and run again for them to appear.

iOS UI with a list of file names.

Tapping on a row will open that file.

Refreshing Files

Whenever you upload a new file, you need to restart the app to see it. Obviously, this isn't ideal.

In this section, you'll use the refreshable view modifier and NotificationCenter to update the list of URLs.

Thankfully, it's super easy to add pull-to-refresh functionality in SwiftUI.

Open ContentView.swift and add the refreshable view modifier after the closing brace for the List view.

.refreshable {
  server.loadFiles()
}

This solves the main issue of having to restart the app to preview new uploads. It would be even better if it happened automatically — in real time — as new files are added or removed.

You can do this by using NotificationCenter.

To add this functionality, open FileServer.swift and a notification observer within the initializer, after the configuration step.

NotificationCenter.default.addObserver(forName: .serverFilesChanged, object: nil, queue: .main) { _ in   
  self.loadFiles()
}

To trigger this notification, open FileWebRouteCollection.swift and add a new function called notifyFileChange() like so:

func notifyFileChange() {
  DispatchQueue.main.async {
    NotificationCenter.default.post(name: .serverFilesChanged, object: nil)
  }
}

Be sure to call it whenever the files inside the documents directory are altered. A good place to do this is before the return statement within the uploadFilePostHandler function.

This triggers the notification when a file has finished uploading successfully, which then tells the FileServer to reload the files, and passes the results back to the UI via a published property.

Run the app and experiment with uploading files through your browser — and watch the iOS app update before your very eyes!

Downloading Files

A file hosting server is nothing without the ability for people to download those files. Enabling downloads is super easy because you've done most of the work already. All you need is a new route to handle such a request.

Open FileWebRouteCollection.swift and create a new handler called downloadFileHandler.

func downloadFileHandler(_ req: Request) throws -> Response {
  guard let filename = req.parameters.get("filename") else {
    throw Abort(.badRequest)
  }
  let fileUrl = try URL.documentsDirectory().appendingPathComponent(filename)
  return req.fileio.streamFile(at: fileUrl.path)
}

This rejects any requests made without a filename, fetches that file from storage, then returns it in the response.

Finally, register the new download route in the collection by adding it to the routes builder within the boot(routes:) function at the top of the file.

routes.get(":filename", use: downloadFileHandler)

The colon syntax marks this particular URL path component as a parameter and exposes its value through the request. Unlike other path components that are explicit, this one can be any string value. For example, if you want to download a file named MyDocument.txt you would open myServer:8080/MyDocument/ in your browser. Vapor will take the MyDocument portion and make it accessible through req.parameters.get("filename") as seen in the previous code block.

Build and run again, but this time navigate to your server in a web browser and click a file.

One of two things will happen. The file will open in the browser or download to your device. Hooray!

That leaves one last thing to take care of: file deletion.

Deleting Files on the Web

To maintain acceptable levels of storage space on your device, you're going to want a way to clean up the files on your server. The delete buttons on the web side of things are ready to go.

So, open FileWebRouteCollection.swift and add a new route to handle deletion requests.

func deleteFileHandler(_ req: Request) throws -> Response {
  guard let filename = req.parameters.get("filename") else {
    throw Abort(.badRequest)
  }
  let fileURL = try URL.documentsDirectory().appendingPathComponent(filename)
  try FileManager.default.removeItem(at: fileURL)
  notifyFileChange()
  return req.redirect(to: "/")
}

This is similar to the download handler. Both reject requests for missing filename parameters, and both construct a URL based on that filename. However, instead of returning the file, it's removed. Finally, a notification triggers to inform the iOS portion of the app that the UI needs updating, and the redirect refreshes the webpage.

Register the deletion route by adding this code to the boot(routes:) function:

routes.get("delete", ":filename", use: deleteFileHandler)

This new route is similar to the download route — except it has an extra path component. Going with the same example as before, while you can download a file with the name MyDocument.txt from myServer:8080/MyDocument/, you can delete that same file by navigating to myServer:8080/delete/MyDocument/. This is the same request that's made when clicking the delete button on the website.

Deleting Files on the App

File deletion wouldn't be complete without being able to do it from the app, too.

Open FileServer.swift and add a new delete function at the bottom of the class:

func deleteFile(at offsets: IndexSet) {
  // 1
  let urlsToDelete = offsets.map { fileURLs[$0] }
  // 2
  fileURLs.remove(atOffsets: offsets)
  // 3
  for url in urlsToDelete {
    try? FileManager.default.removeItem(at: url)
  }
}

An IndexSet is a collection of unique integers. This is great for when you need to delete multiple items at a time.

Here's what this code does:

  1. Creates a list of URLs up for deletion using the provided indexes.
  2. Removes those urls from the array that feeds the app's UI.
  3. Deletes files at those URLs from the device.

Open ContentView.swift and add the onDelete(perform:) view modifier after the closing brace of the ForEach.

.onDelete(perform: server.deleteFile)

Run the app, and you'll now be able to delete files from the server and within the app!

Finally, if you'd rather not have to look up the device name and port when connecting to the server, add the following code after the .refreshable view modifier inside ContentView.swift:

.toolbar {
  ToolbarItem(placement: .principal) {
    Text(ProcessInfo().hostName + ":\(server.port)")
  }
}

This puts the device name and port above the list of files, making it far easier to share this information with others on the same network.

Congratulations! You successfully created an iOS app with a built-in server!

Accessing over the Internet

Just a word on security: You must take precautions and not leave your app running unattended.

If you'd like to allow access to your app from anywhere, you may need to disable firewalls or change some port settings on your network router. Or, you could disable the Wi-Fi on your device and connect to it using cell services.

You'll then need your network's public IP address, which you can find by typing "what's my ip" into a web search. That IP address and port number should be all you need to make a connection.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

This tutorial is only the beginning of what's possible when you combine frontend and backend systems into the same project. Consider how this could be used for multiplayer games or streaming video content without having it go through a third-party service.

Some other ideas to get your inspiration flowing:

  • Restrict who can access your server by creating a web login/password screen.
  • Create a permission system so that only users who upload files can view them.
  • Add a feature that lets users on the iOS side add files to the server.

Want to learn more about SwiftUI and Vapor? Check out the SwiftUI by Tutorials and Server Side Swift with Vapor books.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!