Skip to content

KitRoutes.kt

src/jvmMain/kotlin/afield/app/server/KitRoutes.kt · 147 lines

package afield.app.server

import afield.document.workspace.*
import afield.document.graph.Box
import afield.document.persistence.DocumentShutdownFlush
import afield.ui.output.FieldMarkdown
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File

The HTTP surface behind Welcome's kit library, one route per action that panel offers.

GET /api/kits is the list: every registered package with its manifest and its state, whether the registry and the revisions this host launched with have diverged, and the packages bundled in the checkout's templates/. install and remove edit the registry; restart-status and restart drive afield.document.workspace.KitRestarts; create copies a starting point into the workspace and announces the new document; open-host starts a kit that brings its own host and answers with a URL into it; guide renders the package's Markdown guide as a standalone page, served under a sandboxing Content-Security-Policy because that text belongs to the package rather than to Field.

The three routes that touch an existing document are the careful ones. instances and inspect report a three-way comparison and write nothing; migrate refuses unless the document is closed, flushing and unloading one that is loaded with nothing attached to it first; and activate starts the kit's runtime for a loaded document and launches its boxes in order, or answers with the URL that would open it when it is not loaded. A document is addressed by name and has to resolve to a direct child of the workspace, so a path cannot reach outside it.

Nothing here runs code from a package. Installing is validation and copying; it is the next launch that puts a kit's classes on the classpath.

data class KitPackageRequest(val path: String = "", val id: String = "", val startingPoint: String = "", val name: String = "", val document: String = "")

One request body for every kit route; each reads the fields its own action needs.

@Serializable
data class KitPackageRequest(val path: String = "", val id: String = "", val startingPoint: String = "", val name: String = "", val document: String = "")

data class KitLibraryResponse(val kits: List<KitPackageStatus>, val restartRequired: Boolean, val bundled: List<String>)

The whole kit library as Welcome draws it, including packages bundled in templates/.

@Serializable
data class KitLibraryResponse(val kits: List<KitPackageStatus>, val restartRequired: Boolean, val bundled: List<String>)

fun Route.installKitRoutes(restart: () -> Unit) {

Local kit lifecycle APIs used by Welcome; registration never executes the package's code.

body — 99 lines, from line 48
fun Route.installKitRoutes(restart: () -> Unit) {
    suspend fun library(): KitLibraryResponse = withContext(Dispatchers.IO) {
        val installed = KitPackages.installed().associate { it.id to it.revision }
        val bundled = File(app, "templates").listFiles().orEmpty().filter { File(it, KitPackages.MANIFEST).isFile }.map { it.path }
        KitLibraryResponse(KitPackages.statuses(KitRuntime.errors), installed != KitPackages.active(), bundled)
    }
    get("/api/kits") { call.respond(library()) }
    post("/api/kits/install") {
        try {
            val request = call.receive<KitPackageRequest>()
            withContext(Dispatchers.IO) { KitPackages.install(File(request.path.replaceFirst(Regex("^~(?=/|$)"), System.getProperty("user.home")))) }
            call.respond(library())
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Kit installation failed"))) }
    }
    post("/api/kits/remove") {
        val request = call.receive<KitPackageRequest>()
        withContext(Dispatchers.IO) { KitPackages.remove(request.id) }
        call.respond(library())
    }
    get("/api/kits/restart-status") { call.respond(KitRestarts.status) }
    post("/api/kits/restart") {
        try {
            restart()
            call.respond(KitRestarts.status)
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not prepare kit runtime"))) }
    }
    post("/api/kits/create") {
        try {
            val request = call.receive<KitPackageRequest>()
            val response = withContext(Dispatchers.IO) { KitInstances.create(File(home), request.id, request.startingPoint, request.name) }
            recordRecentDocument(response.filename)
            broadcastWelcomeEvent("kit-document-created")
            call.respond(response)
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not create kit document"))) }
    }
    post("/api/kits/open-host") {
        try {
            val request = call.receive<KitPackageRequest>()
            val kit = KitPackages.installed().singleOrNull { it.id == request.id } ?: error("Kit is not installed")
            val url = withContext(Dispatchers.IO) { KitDedicatedHosts.open(kit, request.startingPoint.ifBlank { null }) }
            call.respond(mapOf("url" to url))
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not open kit host"))) }
    }
    get("/api/kits/guide") {
        val id = call.request.queryParameters["id"]
        val kit = KitPackages.installed().singleOrNull { it.id == id }
        if (kit == null) { call.respond(HttpStatusCode.NotFound); return@get }
        val manifest = KitPackages.read(File(kit.directory))
        val markdown = if (manifest.guide.isBlank()) manifest.description else KitPackages.child(File(kit.directory), manifest.guide).readText()
        call.response.headers.append("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
        call.respondText("<!doctype html><meta charset=utf-8><style>body{background:#000;color:#eee;font:16px/1.5 Iosevka,monospace;padding:24px;max-width:900px}a{color:inherit}pre{white-space:pre-wrap}</style>" + FieldMarkdown.render(markdown), ContentType.Text.Html)
    }
    get("/api/kits/instances") {
        val records = withContext(Dispatchers.IO) {
            File(home).listFiles().orEmpty().filter { it.isDirectory && File(it, KitInstances.RECORD).isFile }
                .map { document -> KitInstances.inspect(document) }
        }
        call.respond(records)
    }
    post("/api/kits/inspect") {
        try {
            val request = call.receive<KitPackageRequest>()
            val document = KitPackages.child(File(home), request.document)
            require(document.parentFile.canonicalFile == File(home).canonicalFile) { "Use a workspace document name" }
            call.respond(withContext(Dispatchers.IO) { KitInstances.inspect(document) })
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not inspect kit instance"))) }
    }
    post("/api/kits/migrate") {
        try {
            val request = call.receive<KitPackageRequest>()
            val document = KitPackages.child(File(home), request.document)
            require(document.parentFile.canonicalFile == File(home).canonicalFile) { "Use a workspace document name" }
            val live = documents.values.firstOrNull { File(it.filename).canonicalFile == document.canonicalFile }
            require(live == null || (live.sessions.isEmpty() && !live.keepRunningWhenDetached)) { "Close this document and its running session before updating" }
            if (live != null) {
                DocumentShutdownFlush.saveIfPending(live, inline = true, reason = "kit-migration")
                live.cleanup()
                documents.entries.removeIf { it.value === live }
            }
            call.respond(withContext(Dispatchers.IO) { KitInstances.migrate(document) })
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not migrate kit instance"))) }
    }
    post("/api/kits/activate") {
        try {
            val request = call.receive<KitPackageRequest>()
            val info = documents.values.singleOrNull { it.documentName == request.document && !it.ephemeral }
            if (info == null || (info.sessions.isEmpty() && !info.keepRunningWhenDetached)) {
                val directory = KitPackages.child(File(home), request.document)
                require(directory.parentFile.canonicalFile == File(home).canonicalFile && KitInstances.record(directory) != null) { "Unknown kit document" }
                call.respond(mapOf("url" to ("/index.html?file=" + java.net.URLEncoder.encode(request.document, Charsets.UTF_8))))
                return@post
            }
            KitRuntime.activate(info)
            val boxes = KitRuntime.launchBoxes(info) ?: error("This document has no package record")
            Box.withBoxContextNow(info.root) { launchBoxesInOrder(boxes) }
            call.respond(mapOf("message" to "Launching ${boxes.size} kit boxes in order."))
        } catch (error: Exception) { call.respond(HttpStatusCode.BadRequest, mapOf("error" to (error.message ?: "Could not activate kit"))) }
    }
}