When serving files with Ktor, there might be times when you need to alter those files. For example, you might want to inject a script in every served HTML file. For this purpose, we can leverage plugins. Plugins can hook at different stages of the request/response pipeline:

Ktor request/response pipeline

Let’s write a plugin that transforms a specific type of files - going with the previous example of injecting a script to every served HTML file. Our plugin needs to:

  • Take a script URL as an input. If not provided, it won’t do anything.
  • Add that script as a <script> element in the <head> of any HTML file served by this Ktor server.
  • Obviously, not interfere with any other file format.

Let’s start by defining an empty plugin and its configuration:

class PluginConfiguration {
  var scriptUrl: String? = null
}

val ScriptInjectionPlugin = createApplicationPlugin(
  name = "ScriptInjectionPlugin",
  createConfiguration = ::PluginConfiguration,
) {
  val scriptUrl = pluginConfig.scriptUrl

  // The rest of our plugin goes here.
}

With this simple definition, we can add our plugin to a Ktor server:

embeddedServer(Netty, port = 8080) {
  install(ScriptInjectionPlugin) {
    scriptUrl = "http://foo.bar/my/injected/script.js"
  }
}

Now, let’s see how we can transform the body. Ktor offers a few handlers to hook into the pipeline shown above. The main ones are:

  • onCall is fairly high level, and is mostly useful to get information about a request (e.g. to log a request).
  • onCallReceive allows to transform data received from the client before it’s processed.
  • onCallRespond allows to transform data before sending it to the client. That’s the one we’re after.

There are a few other handlers for specific use-cases, detailed here.

Let’s hook into onCallRespond, and its helper transformBody. We also need to check if the content type we’re sending is HTML, otherwise, we just forward the response body as-is:

onCallRespond { _ ->
  transformBody { data ->
    if (data is OutgoingContent.ReadChannelContent &&
      data.contentType?.withoutParameters() == ContentType.Text.Html &&
      scriptUrl != null
    ) {
      // We are serving an HTML file.
      transform(data, scriptUrl)
    } else {
      data
    }
  }
}

We can then define our transform() helper, which needs to:

  • Read the body,
  • Transform it,
  • Store it into a OutgoingContent for Ktor to continue its pipeline.

First, let’s get the injection itself out of the way. Let’s assume we have our HTML file into a string, and want a new string with the injected HTML. Jsoup comes in handy for this kind of operations:

private fun injectLiveReloadFragment(target: String, scriptUrl: String): String {
  return Jsoup.parse(target).apply {
    head().appendElement("script").attributes().add("src", scriptUrl)
  }.toString()
}

Now, let’s actually read and return the body:

Note

This transformation assumes that the HTML files processed there are fairly small. As such, it fully reads the channel Ktor provides in memory before transforming it. Any high-traffic server or longer files should probably not read the content in that way.

private suspend fun transform(
  data: OutgoingContent.ReadChannelContent,
  scriptUrl: String,
): OutgoingContent {
  // This channel provided by Ktor gives a view of the file about to be returned.
  val channel = data.readFrom()

  // Let's ready everything in the channel into a String.
  val content = StringBuilder().run {
    while (!channel.isClosedForRead) {
      channel.readUTF8LineTo(this)
      append('\n')
    }
    toString()
  }

  // Inject our script URL.
  val htmlContent = injectLiveReloadFragment(content, scriptUrl)

  // Prepare our new content (htmlContent contains it as a string) for Ktor to process.
  return WriterContent(
    body = {
      withContext(Dispatchers.IO) {
        write(htmlContent)
      }
    },
    contentType = ContentType.Text.Html,
  )
}