feat(api): bootstrap consumer plugins from Gradle configuration

This commit is contained in:
Cayden Tisdale 2026-09-06 00:13:08 -04:00
parent eddfc0c5f9
commit bf534af932
No known key found for this signature in database
5 changed files with 237 additions and 58 deletions

152
README.md
View File

@ -1,67 +1,137 @@
# Pumpkin Plugin API for Kotlin
This repository builds the Kotlin bindings for [Pumpkin](https://github.com/Pumpkin-MC/Pumpkin) plugins using WebAssembly (Wasm) components.
Build Kotlin plugins for [Pumpkin](https://github.com/Pumpkin-MC/Pumpkin) as WebAssembly components.
The `api` Gradle module is a publishable Kotlin/WASI library and generated-source snapshot. It generates the WIT bindings only while API maintainers build or publish the library; the consumer Gradle plugin will unpack that snapshot before compiling a plugin, so plugin authors do not install or run `wit-bindgen`.
The `api` module packages generated Kotlin bindings, the WIT export bridge, and the WIT snapshot and WASI adapter needed to build a component. The `gradle-plugin` module integrates that package into a plugin author's build. Plugin authors do not need Rust, `wit-bindgen`, or a WIT checkout.
The root component template remains available while the consumer Gradle plugin is being moved into `gradle-plugin`.
Kotlin/Wasm component support is still experimental. The planned release destinations are Maven Central for the API and the Gradle Plugin Portal for the build plugin, though that is not certain yet.
## API maintainer requirements
<!-- TODO: Replace the version placeholders below with published versions once available. -->
Kotlin/Wasm + components it's still in it's early stages, and not as straightforward as could be. You will need to have several things installed for everything to work.
## Create a plugin
- JDK 17 or later
- To run Gradle 9.4
- Rust
- This is required only to build or publish the API, because `wit-bindgen` is built from a particular Kotlin-enabled fork.
- You only need a default Rust install for your host platform. NOT for any WebAssembly targets
Create a Gradle project with a Gradle 9.x wrapper and JDK 17 or later. You do not need to clone this repository.
## Building the API
Initialize the WIT submodule after cloning:
```sh
git submodule update --init --recursive
```
Publish a local development artifact with:
```sh
./gradlew :api:publishToMavenLocal
```
This generates the `pumpkin` package bindings under `api/build/generated/`, compiles them with the package-owned WIT export bridge, and publishes the KLIB plus a generated-source archive. The source archive includes the pinned WIT snapshot and Preview 1 reactor adapter needed to assemble a component.
In `settings.gradle.kts`:
```kotlin
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = "my-plugin"
```
In `build.gradle.kts`:
```kotlin
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
kotlin("multiplatform") version "2.4.0"
id("io.github.pumpkin-mc.plugin") version "<plugin-version>"
}
repositories {
mavenLocal()
mavenCentral()
}
// The consumer Gradle plugin resolves the matching generated-source archive.
kotlin {
@OptIn(ExperimentalWasmDsl::class)
wasmWasi {
nodejs()
binaries.executable()
}
}
pumpkin {
apiVersion.set("<api-version>")
pluginClass.set("example.MyPlugin")
}
```
The API artifact exposes `PumpkinPlugin` and `registerPlugin(...)`; plugin authors should implement and register that class rather than defining the generator-specific `PluginRootFunctionsExportsImpl` or `MetadataImpl` types.
> [!important]
> Put your implementation in `src/wasmWasiMain/kotlin/example/MyPlugin.kt`. The `wasmWasiMain` source set is required; `src/main/kotlin` is not the source directory for this target.
## Root template build
```kotlin
package example
The original root template still builds a complete component with `./gradlew build`. Gradle skips tool installation, binding generation, and component assembly when their declared inputs are unchanged. Use `./gradlew clean` to remove generated build output; it intentionally retains the installed tool cache under `tools/`.
import plugin.PluginMetadata
import plugin.PumpkinPlugin
import pumpkin.Context
import pumpkin.Logging
## Updating the WIT
class MyPlugin : PumpkinPlugin() {
override fun metadata() = PluginMetadata(
name = "my-plugin",
version = "0.1.0",
authors = listOf("Your name"),
description = "My first Kotlin plugin",
dependencies = emptyList(),
permissions = emptyList(),
)
Keep an eye on github.com/Pumpkin-MC/pumpkin-plugin-wit
override fun onLoad(context: Context.Context): Result<Unit> {
Logging.log(Logging.Level.INFO, "Hello from Kotlin!")
return Result.success(Unit)
}
}
```
The configured class must extend `PumpkinPlugin` and be constructible without arguments. Override the callbacks your plugin uses; most optional handler defaults throw if invoked without an implementation.
## Build and load
From your plugin project:
If you start getting errors like
`main ThreadId(01) pumpkin::plugin: Failed to load plugin from "./plugins\\my_plugin.wasm": Wasm plugin initialization error: plugin failed to load with error: component imports instance 'pumpkin:plugin/gui@0.1.0', but a matching implementation was not found in the linker`
or other "linker" errors when loading your plugin into Pumpkin, update the `wit` submodule
```sh
cd wit
git pull origin master
cd ..
./gradlew build
```
----
The Gradle plugin downloads a pinned prebuilt `wasm-tools`, compiles the Kotlin code, assembles the component, and validates it. The component is written to `build/<project-name>.wasm` (`build/my-plugin.wasm` above). Copy it into the Pumpkin server's `plugins` directory and start the server.
### Attributions
To assemble only the component, run `./gradlew assemblePluginRelease`. Gradle skips tasks when their inputs are unchanged. The consumer's generated sources and downloaded tools live under `build/`, so `./gradlew clean` removes them.
This template was primarily derived from [@jmrtsh](https://github.com/jmrtsh)'s work on [Kotlin/sample-wasi-http-kotlin](https://github.com/Kotlin/sample-wasi-http-kotlin), which is licensed under [Apache-2.0](https://github.com/Kotlin/sample-wasi-http-kotlin/blob/main/LICENSE)
## How the dependency and bootstrap work
The API publishes both a Kotlin/WASI KLIB and a generated-source archive. Currently, WIT exports must be compiled as part of the final plugin to be retained by the Kotlin/Wasm linker. The Gradle plugin therefore resolves the API's source archive through `pumpkinApiSources`, unpacks it, and adds it to `wasmWasiMain`. You do not add a separate `implementation(...)` dependency or run binding generation yourself.
`generatePumpkinPluginBootstrap` generates a factory from `pumpkin.pluginClass`:
```kotlin
package plugin
internal fun createPlugin(): PumpkinPlugin = example.MyPlugin()
```
This replaces the API's placeholder factory during consumer compilation. On the first exported callback, the API bridge constructs and caches your plugin instance. Later callbacks use the same instance. There is no need for `main()`, a top-level registration initializer, or a call to `registerPlugin(...)`. The package also supplies the generator-specific `PluginRootFunctionsExportsImpl` and `MetadataImpl` bridges.
## Server compatibility
Choose an API version compatible with your Pumpkin server. Errors such as `no export ... found`, `type-checking export func ...`, or missing imports indicate a possible mismatch between the plugin's packaged WIT and the server's contract. Update `pumpkin.apiVersion` to a compatible release and rebuild the component.
## API development
Contributors building the API itself need JDK 17 or later and a host Rust toolchain. Gradle builds the pinned Kotlin-enabled `wit-bindgen` from source; no Rust WebAssembly target is required.
From this repository, generate the bindings and publish both development packages locally:
```sh
git submodule update --init --recursive
./gradlew :api:publishToMavenLocal :gradle-plugin:publishToMavenLocal
```
To test these local packages in a consumer, add `mavenLocal()` before the other repositories in both `settings.gradle.kts`'s `pluginManagement.repositories` and `build.gradle.kts`'s `repositories`. Set both version placeholders to `0.1.0-dev`. After republishing changes under that version, run `./gradlew build --refresh-dependencies` in the consumer.
The repository's legacy root template still uses the earlier `main()` registration approach. Use the separate consumer setup above for the bootstrap integration.
### Updating the WIT
API maintainers should update the `wit` submodule to the revision compatible with the target Pumpkin server, then regenerate and republish the API. Commit the updated submodule revision with any required callback changes so builds use the same contract.
## Attributions
This project was primarily derived from [@jmrtsh](https://github.com/jmrtsh)'s work on [Kotlin/sample-wasi-http-kotlin](https://github.com/Kotlin/sample-wasi-http-kotlin), which is licensed under [Apache-2.0](https://github.com/Kotlin/sample-wasi-http-kotlin/blob/main/LICENSE).

View File

@ -0,0 +1,8 @@
package plugin
/**
* Replaced by the consumer Gradle plugin with a direct reference to the plugin implementation.
*/
internal fun createPlugin(): PumpkinPlugin = error(
"No plugin implementation is configured. Set pumpkin.pluginClass in build.gradle.kts.",
)

View File

@ -26,6 +26,13 @@ abstract class PumpkinPlugin {
args: Command.ConsumedArgs,
): Result<Int> = unsupportedCallback("handleCommand")
open fun handleCommandSuggestion(
handlerId: UInt,
sender: Command.CommandSender,
server: Server.Server,
request: Command.SuggestionRequest,
): Command.CommandSuggestions = unsupportedCallback("handleCommandSuggestion")
open fun handleEvent(
eventId: UInt,
server: Server.Server,
@ -52,6 +59,12 @@ abstract class PumpkinPlugin {
open fun handleAiGoalStop(goalId: UInt, server: Server.Server, entity: World.Entity) =
unsupportedCallback<Unit>("handleAiGoalStop")
open fun handleGeneratePhase(
generatorId: UInt,
phase: World.GenerationPhase,
chunk: World.ChunkBuffer,
) = unsupportedCallback<Unit>("handleGeneratePhase")
}
typealias PluginMetadata = Metadata.PluginMetadata
@ -64,8 +77,8 @@ fun registerPlugin(plugin: PumpkinPlugin) {
registeredPlugin = plugin
}
private fun requirePlugin(): PumpkinPlugin = checkNotNull(registeredPlugin) {
"No Pumpkin plugin has been registered. Call registerPlugin(...) during initialization."
private fun requirePlugin(): PumpkinPlugin = registeredPlugin ?: createPlugin().also {
registeredPlugin = it
}
private fun <T> unsupportedCallback(name: String): T =
@ -93,6 +106,13 @@ internal class PluginRootFunctionsExportsImpl {
override fun handleEvent(eventId: UInt, server: Server.Server, event: Event.Event) =
requirePlugin().handleEvent(eventId, server, event)
override fun handleCommandSuggestion(
handlerId: UInt,
sender: Command.CommandSender,
server: Server.Server,
request: Command.SuggestionRequest,
) = requirePlugin().handleCommandSuggestion(handlerId, sender, server, request)
override fun handleTask(handlerId: UInt, server: Server.Server) =
requirePlugin().handleTask(handlerId, server)
@ -113,6 +133,12 @@ internal class PluginRootFunctionsExportsImpl {
override fun handleAiGoalStop(goalId: UInt, server: Server.Server, entity: World.Entity) =
requirePlugin().handleAiGoalStop(goalId, server, entity)
override fun handleGeneratePhase(
generatorId: UInt,
phase: World.GenerationPhase,
chunk: World.ChunkBuffer,
) = requirePlugin().handleGeneratePhase(generatorId, phase, chunk)
}
}

View File

@ -16,12 +16,14 @@ import org.gradle.kotlin.dsl.register
import org.gradle.process.CommandLineArgumentProvider
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.targets.wasm.binaryen.BinaryenExec
import java.net.URI
abstract class PumpkinExtension @Inject constructor(objects: ObjectFactory) {
val apiGroup: Property<String> = objects.property(String::class.java).convention("io.github.pumpkin-mc")
val apiArtifact: Property<String> = objects.property(String::class.java).convention("pumpkin-api-kt")
val apiVersion: Property<String> = objects.property(String::class.java).convention("0.1.0-dev") // Replace
val apiVersion: Property<String> = objects.property(String::class.java).convention("0.1.0-dev")
val wasmToolsVersion: Property<String> = objects.property(String::class.java).convention("1.258.0")
val pluginClass: Property<String> = objects.property(String::class.java)
}
class PumpkinPlugin : Plugin<Project> {
@ -57,14 +59,44 @@ class PumpkinPlugin : Plugin<Project> {
group = "build setup"
description = "Unpacks the published Pumpkin API source snapshot for component compilation."
from(project.provider { project.zipTree(apiSources.singleFile) })
exclude("**/plugin/PluginFactory.kt")
into(project.layout.buildDirectory.dir("generated/pumpkin-api"))
}
val pluginBootstrapDirectory = project.layout.buildDirectory.dir("generated/pumpkin-plugin-bootstrap")
val pluginFactorySource = pluginBootstrapDirectory.map { it.file("plugin/PluginFactory.kt") }
val generatePluginBootstrap = project.tasks.register("generatePumpkinPluginBootstrap") {
group = "build setup"
description = "Generates the bridge from Pumpkin's WIT exports to the plugin implementation."
inputs.property("pluginClass", extension.pluginClass)
outputs.file(pluginFactorySource)
doLast {
val pluginClass = checkNotNull(extension.pluginClass.orNull) {
"Set pumpkin.pluginClass to your no-argument PumpkinPlugin implementation."
}
val output = pluginFactorySource.get().asFile
output.parentFile.mkdirs()
output.writeText(
"""
package plugin
internal fun createPlugin(): PumpkinPlugin = $pluginClass()
""".trimIndent() + "\n",
)
}
}
project.extensions.getByType(KotlinMultiplatformExtension::class.java)
.sourceSets.named("wasmWasiMain") {
kotlin.srcDir(unpackApiSources)
kotlin.srcDir(pluginBootstrapDirectory)
}
project.tasks.named("compileKotlinWasmWasi") {
dependsOn(generatePluginBootstrap)
}
val unpackedApiDirectory = unpackApiSources.map { it.destinationDir }
val witDirectory = unpackedApiDirectory.map { it.resolve("wit/v0.1") }
val reactorAdapter = unpackedApiDirectory.map { it.resolve("wasi/wasi_snapshot_preview1.reactor.wasm") }
@ -73,23 +105,66 @@ class PumpkinPlugin : Plugin<Project> {
"tools/wasm-tools/${extension.wasmToolsVersion.get()}",
)
val wasmTools = wasmToolsDirectory.map { it.file("bin/wasm-tools$executableSuffix") }
val wasmToolsTarget = when {
System.getProperty("os.name").startsWith("Mac", ignoreCase = true) &&
System.getProperty("os.arch") in setOf("aarch64", "arm64") -> "aarch64-macos"
System.getProperty("os.name").startsWith("Mac", ignoreCase = true) -> "x86_64-macos"
System.getProperty("os.name").startsWith("Windows", ignoreCase = true) &&
System.getProperty("os.arch") in setOf("aarch64", "arm64") -> "aarch64-windows"
System.getProperty("os.name").startsWith("Windows", ignoreCase = true) -> "x86_64-windows"
System.getProperty("os.arch") in setOf("aarch64", "arm64") -> "aarch64-linux"
System.getProperty("os.arch") in setOf("x86_64", "amd64") -> "x86_64-linux"
else -> error("Unsupported wasm-tools host architecture: ${System.getProperty("os.arch")}")
}
val wasmToolsArchiveExtension = if (wasmToolsTarget.endsWith("windows")) "zip" else "tar.gz"
val wasmToolsUrl = "https://github.com/bytecodealliance/wasm-tools/releases/download/v${extension.wasmToolsVersion.get()}/" +
"wasm-tools-${extension.wasmToolsVersion.get()}-$wasmToolsTarget.$wasmToolsArchiveExtension"
val installWasmTools = project.tasks.register("installWasmTools", org.gradle.api.tasks.Exec::class.java) {
val installWasmTools = project.tasks.register("installWasmTools") {
group = "build setup"
description = "Installs the pinned wasm-tools executable."
description = "Downloads the pinned wasm-tools release executable."
inputs.property("version", extension.wasmToolsVersion)
outputs.dir(wasmToolsDirectory)
inputs.property("target", wasmToolsTarget)
inputs.property("url", wasmToolsUrl)
outputs.file(wasmTools)
val cargoHome = project.providers.environmentVariable("CARGO_HOME")
.orElse(project.providers.systemProperty("user.home").map { "$it/.cargo" })
commandLine(
cargoHome.map { "$it/bin/cargo$executableSuffix" }.get(),
"install",
"wasm-tools",
"--version", extension.wasmToolsVersion.get(),
"--locked",
"--root", wasmToolsDirectory.get().asFile.absolutePath,
)
doLast {
val installationDirectory = wasmToolsDirectory.get().asFile
val archive = temporaryDir.resolve("wasm-tools.$wasmToolsArchiveExtension")
val installedExecutable = wasmTools.get().asFile
installationDirectory.deleteRecursively()
installationDirectory.mkdirs()
installedExecutable.parentFile.mkdirs()
URI(wasmToolsUrl).toURL().openStream().use { input ->
archive.outputStream().use { output -> input.copyTo(output) }
}
fun runCommand(vararg command: String) {
check(ProcessBuilder(*command).inheritIO().start().waitFor() == 0) {
"wasm-tools archive extraction failed"
}
}
if (wasmToolsTarget.endsWith("windows")) {
runCommand(
"powershell", "-NoProfile", "-Command",
"Expand-Archive -Force '$archive' '$installationDirectory'; " +
"Get-ChildItem -Path '$installationDirectory' -Recurse -Filter wasm-tools.exe | " +
"Select-Object -First 1 | Copy-Item -Destination '$installedExecutable'",
)
} else {
runCommand("tar", "-xzf", archive.absolutePath, "-C", installationDirectory.absolutePath, "--strip-components=1")
}
if (!installedExecutable.isFile) {
val downloadedExecutable = installationDirectory.walkTopDown().firstOrNull {
it.isFile && it.name == "wasm-tools$executableSuffix"
}
checkNotNull(downloadedExecutable) { "wasm-tools archive did not contain wasm-tools$executableSuffix" }
downloadedExecutable.copyTo(installedExecutable, overwrite = true)
}
installedExecutable.setExecutable(true)
}
}
val projectWasmName = project.name

2
wit

@ -1 +1 @@
Subproject commit 160251fb0b15ad529bf7c4939140bb5fa0c07035
Subproject commit 1ad73fff1e0a9e21b99255816df5f99f6260c1b9