Skip to content

KitDedicatedHost.kt

src/jvmMain/kotlin/afield/document/workspace/KitDedicatedHost.kt · 104 lines

package afield.document.workspace

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.Serializable
import java.io.File
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration

The compatibility route for a project that already has its own launcher, workspace and document conventions and wants to appear in Welcome's kit library without adopting Field's. Such a kit declares a host block in its field-kit.json naming a launcher script, a loopback HTTP endpoint and the workspace that launcher owns, and Field then treats it as something to start rather than something to load: none of its runtime declarations join the calling host's classpath, and its documents keep whatever versioning scheme the project already uses.

KitDedicatedHosts.open is the whole action, and it runs only from an explicit Open kit host or Create in kit host in Welcome, never from adding a kit. It probes /api/instance-info at the declared endpoint first and reuses a host that is already up, refusing outright when a different workspace answers there; otherwise it runs the launcher from the kit's source directory with Field's own port and path variables stripped from the environment, appends its output to logs/<id>.log under the kits directory, and polls for up to three minutes. Creating a document is then a POST to that host's own starting-point API, so the running project copies the seed by its own rules and hands back the URL to open.

Two checks hold the arrangement together. KitPackages.read calls KitDedicatedHosts.validate on every manifest, so a non-loopback endpoint, a query string, user info in the URL, or a launcher path escaping the package is refused when the kit is added rather than when it is run. And KitDedicatedHosts.open compares the launcher's hash in the source directory against the one in the installed snapshot, so the script that executes is the script that was there when the kit was added.

data class KitDedicatedHost(val launcher: String, val endpoint: String, val workspace: String,

Compatibility for an existing project launcher. Its original workspace and runtime remain authoritative.

@Serializable
data class KitDedicatedHost(val launcher: String, val endpoint: String, val workspace: String,
    val environment: Map<String, String> = emptyMap())

object KitDedicatedHosts

Starts an existing local project without rewriting its launcher, manifests or existing documents.

object KitDedicatedHosts {
    private val mapper = jacksonObjectMapper()
    private val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()

fun validate(root: File, host: KitDedicatedHost)

    fun validate(root: File, host: KitDedicatedHost) {
        require(KitPackages.child(root, host.launcher).isFile) { "Missing dedicated-host launcher" }
        val uri = URI(host.endpoint)
        require(uri.scheme == "http" && uri.host in setOf("localhost", "127.0.0.1") && uri.port > 0 && uri.rawQuery == null && uri.userInfo == null) {
            "Dedicated kit hosts need an explicit loopback HTTP endpoint"
        }
        KitPackages.child(root, host.workspace)
        require(host.environment.keys.all { Regex("[A-Z][A-Z0-9_]*").matches(it) }) { "Invalid host environment key" }
    }

fun open(kit: InstalledKit, startingPoint: String? = null): String

The launcher is invoked only by an explicit Run/Create action, never by installation.

body — 46 lines, from line 58
    @Synchronized
    fun open(kit: InstalledKit, startingPoint: String? = null): String {
        val manifest = KitPackages.read(File(kit.directory))
        val host = manifest.host ?: error("This kit runs in the current Field host")
        val source = File(kit.source)
        validate(source, host)
        require(KitPackages.hash(KitPackages.child(source, host.launcher)) == KitPackages.hash(KitPackages.child(File(kit.directory), host.launcher))) {
            "The dedicated launcher changed. Check the kit source for updates first."
        }
        val base = host.endpoint.trimEnd('/')
        val workspace = KitPackages.child(source, host.workspace)
        fun ready(): Boolean = runCatching {
            val response = client.send(HttpRequest.newBuilder(URI("$base/api/instance-info")).timeout(Duration.ofSeconds(2)).GET().build(), HttpResponse.BodyHandlers.ofString())
            if (response.statusCode() != 200) return@runCatching false
            val actual = mapper.readTree(response.body()).path("workspace").asText()
            require(File(actual).canonicalFile == workspace.canonicalFile) { "A different Field workspace owns ${host.endpoint}" }
            true
        }.getOrElse { if (it is IllegalArgumentException) throw it else false }
        if (!ready()) {
            val launcher = KitPackages.child(source, host.launcher)
            val command = when (launcher.extension.lowercase()) { "sh" -> listOf("bash", launcher.path); "ps1" -> listOf("powershell", "-File", launcher.path); else -> listOf(launcher.path) }
            val builder = ProcessBuilder(command).directory(source)
            listOf("PORT", "AFIELD_HTTP_PORT", "AFIELD_EXTENSION_DIRS", "FIELD_HOME", "APP_URL", "FIELD_ELECTRON_USER_DATA",
                "AFIELD_SKIP_BUILD", "FIELD_ELECTRON_CDP_PORT", "AFIELD_KITS_DIR", "AFIELD_WORKSPACE_PREFERENCE_FILE").forEach { builder.environment().remove(it) }
            host.environment.forEach { (key, value) -> builder.environment()[key] = value.replace("\${source}", source.canonicalPath) }
            val log = File(KitPackages.directory, "logs/${kit.id}.log").also { it.parentFile.mkdirs() }
            val process = builder.redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.appendTo(log)).start()
            val deadline = System.currentTimeMillis() + 180000
            while (!ready() && System.currentTimeMillis() < deadline) {
                require(process.isAlive || ready()) { "Kit launcher exited; see ${log.path}" }
                Thread.sleep(300)
            }
            require(ready()) { "Kit host is not ready; see ${log.path}" }
        }
        if (startingPoint == null) return "$base/welcome"
        val point = manifest.startingPoints[startingPoint] ?: error("Unknown starting point")
        val name = File(point.path).name
        val response = client.send(HttpRequest.newBuilder(URI("$base/api/starting-points/open"))
            .header("Content-Type", "application/json").timeout(Duration.ofSeconds(60))
            .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(mapOf("name" to name)))).build(), HttpResponse.BodyHandlers.ofString())
        require(response.statusCode() == 200) { "Dedicated host could not create '$name': ${response.body().take(300)}" }
        val document = mapper.readTree(response.body()).path("filename").asText()
        require(document.isNotBlank() && !document.contains('/') && !document.contains('\\')) { "Dedicated host returned an invalid document" }
        return "$base/index.html?file=" + java.net.URLEncoder.encode(document, Charsets.UTF_8)
    }
}