Serving static files with Ktor for a static website seems easy enough:

fun main() {
  embeddedServer(Netty, port = 8080) {
    routing {
      static {
        files("static-data")
      }
    }
  }.start(wait = true)
}

Done? Not quite. Opening http://localhost:8080 will only get you a 404, even if you have an index.html file in the static-data folder. You have to let Ktor know you want to opt-in serving a default file. Let’s try again (omitting anything outside the static block):

static {
  files("static-data")
  default("static-data/index.html")
}

Surely now this works? Well, kind of. The root folder will have a default index.html, but sadly if you have any sub-folders with index.html files inside, they won’t be served by default - that default() function only works for the top-level folder. Let’s work around that.

Let’s store our static-data directory into a File variable, replace the files function call by our own extension. Note that this extension will handle the fallback to index.html in all cases, so we can also remove the call to default():

val staticData = File("/path/to/static-data")

static {
  filesWithDefaultIndex(staticData)
}

fun Route.filesWithDefaultIndex(dir: File) {
}

In there, we’ll need a get handler:

fun Route.filesWithDefaultIndex(dir: File) {
  get("{static_path...}") {}
}

This will catch anything inside the static path, and let us access the requested path through call.parameters.getAll("static_path"). This gets us a list of URL segments that we need to join with File.separator:

val relativePath = call.parameters
  .getAll("static_path")
  ?.joinToString(File.separator) ?: return@get

Now, we have three options:

  • The requested path points to an existing file. Great! We can just return it.
  • The requested path points to a folder. If there’s an index.html file in there, let’s serve it.
  • In any other case (the requested path doesn’t exist, or points to a folder without an index.html file), we let Ktor handle that (most likely returning a 404).

We can easily access the corresponding local file, and its fallback if it ends up being a directory:

val combinedDir = staticRootFolder?.resolve(dir) ?: dir
val file = combinedDir.combineSafe(relativePath)
val fallbackFile = file.combineSafe("index.html")

Now that we have all those defined, here’s how we take the decision on what to serve:

val localFile = when {
  file.isFile -> file
  file.isDirectory && fallbackFile.isFile -> fallbackFile
  else -> return@get
}

And finally, we serve the file:

call.respond(LocalFileContent(localFile, ContentType.defaultForFile(localFile)))

Great! Let’s see what this looks like all together:

fun Route.filesWithDefaultIndex(dir: File) {
  val combinedDir = staticRootFolder?.resolve(dir) ?: dir
  get("{static_path...}") {
    val relativePath = call.parameters
      .getAll("static_path")
      ?.joinToString(File.separator) ?: return@get
    val file = combinedDir.combineSafe(relativePath)
    val fallbackFile = file.combineSafe("index.html")

    val localFile = when {
      file.isFile -> file
      file.isDirectory && fallbackFile.isFile -> fallbackFile
      else -> return@get
    }

    call.respond(LocalFileContent(localFile, ContentType.defaultForFile(localFile)))
  }
}

Now if you try to open http://localhost:8080 or http://localhost:8080/foo, both should work (assuming there’s index.html files both at the root and in a folder named foo). But if you open http://localhost:8080/foo/, with a trailing slash, it still doesn’t work. That’s because Ktor handles the two routes (with and without trailing slash) differently (for good reasons, see here for some context).

In our case, that’s not what we want. Luckily, Ktor comes with a plug-in to address this. All that’s needed is to install it like any other plugin:

install(IgnoreTrailingSlash)

And now everything should work as expected.