A Minecraft nREPL

Connecting a nREPL to a running Minecraft server gives you all the power of the REPL while developing your plugins! You no longer need to go through the lengthy compile, build jar, copy to server, and restart server process every time you make a change to your plugin.

Background

Bukkit is the developer Minecraft API. Craftbukkit is a series of modifications to the official Minecraft server that enables custom plugins. There are 3 main ways you can run a Minecraft server with plugins: Spigot, Paper, or Glowstone. All the server implementations support the Bukkit API.

Clojure is a Lisp that compiles to JVM bytecode. It is a dynamic, functional language. If unfamiliar, I encourage you to read through the Rationale.

There is no built-in dependency resolution for Bukkit plugins (see Using third party libraries when building plugins and Use external libraries). Dependency support is necessary since, at a bare minimum, we must depend on Clojure.

Approach

We have two options: 1) shade dependencies (e.g., Maven Shade) 2) alter the classpath when starting the JVM.

Maven shade sounds like the defacto standard in the Bukkit plugin community. Personally, I think this is fundamentally the wrong approach. We have dependency resolution for a reason — we want only a single version of a library available at runtime. Perhaps the Minecraft plugin community can get away with it since most plugins don’t depend on external libraries. Path 2 is also quite natual for Clojurists coming from the tools-deps approach. This is the path we will take.

The goal is to create a wrapper script that will launch the server with a custom classpath. Then we’ll create a minimal plugin in Clojure that starts a nREPL server.

Resolving plugin dependencies

For this example, we’ll also be using Paper. Download the Paper server jar from the download page. Place the jar in a new directory, henceforth referred to as the server root directory.

In the serve root directory, try launching the server normally with the below command (note the java prop flag automatically agrees to the EULA).

java -Dcom.mojang.eula.agree=true -jar paper-375.jar nogui

Launching the jar for the first time will download the official Minecraft server jar, patch it, and then start the server up.

Since we want to alter the classpath of the launched JVM, we must switch from the -jar flag to the -cp flag. That means we’ll need to know the name of the main class launched when using the -jar flag. From inspecting the contents of MANIFEST.MF in paper-375.jar, we can see main class picked up — io.papermc.paperclip.Paperclip.

Manifest-Version: 1.0
Launcher-Agent-Class: io.papermc.paperclip.Agent
Built-By: jenkins
Multi-Release: true
Created-By: Apache Maven 3.6.0
Build-Jdk: 1.8.0_252
Main-Class: io.papermc.paperclip.Paperclip

So we must launch our JVM with an altered classpath and specify the io.papermc.paperclip.Paperclip main class. Our goal is to produce a command that looks something like this.

java -cp <our classpath>:paper-375.jar io.papermc.paperclip.Paperclip nogui

We’ll create a small clojure CLI wrapper script to create the classpath for us. Paste this into a file named make-cp.sh at the server root directory.

#!/bin/sh
"exec" "clojure" "-Sdeps" "{:deps,{org.clojure/tools.deps.alpha,{:mvn/version,\"0.8.695\"}org.slf4j/slf4j-nop{:mvn/version,\"1.7.30\"}}}" "$0" "$@"
(ns make-classpath
  (:require
    [clojure.java.io :as io]
    [clojure.string :as str]
    [clojure.tools.deps.alpha :as deps])
  (:import (java.io File)
           (java.util.jar JarFile)))

(defn list-jars
  [dir]
  (filter (fn [^File f]
            (and (.isFile f)
                 (str/ends-with? (.getName f) ".jar")))
          (file-seq (io/file dir))))

(defn plugin-name
  [file-name]
  (let [[n] (str/split file-name #"\.jar")]
    n))

(defn gen-deps
  [jar-files]
  {:deps (into {}
               (map (fn [plugin-jar-f]
                      [(symbol (plugin-name (.getName plugin-jar-f)))
                       {:local/root (.getCanonicalPath plugin-jar-f)}]))
               jar-files)})

(defn lib-map-without-self-jars
  [lib-map jar-files]
  (let [exclude-paths (into #{}
                            (map (fn [f]
                                   (.getCanonicalPath f)))
                            jar-files)]
    (into {}
          (remove (fn [[_ {:keys [local/root]}]]
                    (contains? exclude-paths root)))
          lib-map)))

(defn make-classpath
  [dir]
  (let [jars (list-jars dir)]
    (-> (gen-deps jars)
        (deps/resolve-deps nil)
        (lib-map-without-self-jars jars)
        (deps/make-classpath nil nil))))

(defn -main
  [& args]
  (let [path (first args)
        plugins-path (io/file path "plugins")
        out-path "cp.txt"]
    (println (str "plugins path: " plugins-path))
    (spit out-path (make-classpath plugins-path))
    (println "wrote to" out-path)))

(apply -main *command-line-args*)

This script creates a deps map and passes it tools-deps to create a classpath string. It then writes the classpath string to a file named cp.txt. The deps map gets created by scanning through the plugins directory and finding all jar files. Those jar files are passed to tools-deps as a local jar via :local/root.

For the actual launch script, we’ll create another file called run.sh in the server root directory with the following content.

#!/usr/bin/env bash

set -euox pipefail
./make-cp.sh
java -cp "$(cat cp.txt):paper-375.jar" io.papermc.paperclip.Paperclip nogui

This script does exactly what we discussed before — creates the classpath and launches the JVM with that classpath.

Writing the plugin

A Bukkit plugin really only requires two things: a Java class that extends JavaPlugin and a plugin.yml. All the source code for this plugin is on GitHub.

Create a new directory for your plugin source code, henceforth referred to as the plugin root directory. You’ll likely want to create this directory outside of the server root directory.

First we’ll create our deps.edn for our plugin in the plugin root directory. We include nrepl since our objective is to launch an nREPL server.

{:paths     ["src" "classes" "resources"]
 :deps      {org.clojure/clojure {:mvn/version "1.10.1"}
             nrepl               {:mvn/version "0.7.0"}}
 :mvn/repos {"papermc" {:url "https://papermc.io/repo/repository/maven-public/"}}
 :aliases   {:dev   {:extra-deps {com.destroystokyo.paper/paper-api {:mvn/version "1.15.2-R0.1-20200701.072435-307"}}}
             :build {:extra-paths ["dev"]}
             :jar   {:extra-deps {seancorfield/depstar {:mvn/version "1.0.94"}}
                     :main-opts  ["-m" "hf.depstar.jar"]}}}

In src/dev/kwill/plugin_example.clj, paste in the below the "meat" of our plugin. All we’re doing is launching a nrepl server when the plugin gets enabled.

(ns dev.kwill.clj-example-plugin
  (:gen-class
    :name dev.kwill.clj-example-plugin.Main
    :extends org.bukkit.plugin.java.JavaPlugin))

(defn -onEnable
  [this]
  (let [port 7888]
    (println "launching nrepl server on port" port)
    (@(requiring-resolve 'nrepl.server/start-server) :port port)))

(defn -onDisable
  [this]
  (println "plugin disable..."))

The plugin.yml tells the server how to load your plugin. We’ll define a basic one in resources/plugin.yml.

name: clj-example-plugin
version: 0.1.0
author: Kenny
main: dev.kwill.clj-example-plugin.Main
api-version: 1.15

name is the name of the plugin. The main parameter should be the same as the :name of our generated class above. See plugin.yml for a complete reference of what you can set here.

At the plugin root directory, create a file at dev/build/compile.clj with the following content. This is a small script we’ll call to compile our Clojure code.

(ns build.compile
  (:require
    [dev.kwill.clj-example-plugin]))

(defn -main
  [& args]
  (println "compiling...")
  (compile 'dev.kwill.clj-example-plugin))

We now have everything needed to build the plugin jar! We’ll create a single bash script to nicely tie it all together. Create bin/build.sh in the plugin root directory with the following content.

#!/usr/bin/env bash

set -euo pipefail

jarName="clj-example-plugin.jar"

clojure -A:dev -m build.compile
clojure -Spom
echo "writing jar to ${jarName}"
clojure -A:jar ${jarName}
echo "success!"

After running ./bin/build.sh you should have a jar file named plugin-example.jar in your plugin’s directory. Copy the jar file into your server’s plugins directory. Run your ./run.sh script to boot up the server. If all goes well, you should see the following log lines towards the bottom.

[19:34:39 INFO]: [clj-example-plugin] Enabling clj-example-plugin v0.1.0
[19:34:39 INFO]: launching nrepl server on port 7888

We now have an nREPL running in the server’s JVM process! If you connect to the nREPL on port 7888, you can interact with the game live. For example, we can list the currently online players.

(Bukkit/getOnlinePlayers)
=> []

Now if we join the server and run the function again.

(Bukkit/getOnlinePlayers)
=> [#object[org.bukkit.craftbukkit.v1_15_R1.entity.CraftPlayer 0x5696a007 "CraftPlayer{name=FreeRangePig}"]]

The full Bukkit API is now available to you in a REPL.

Next steps

The above process could likely be simplified with one or two small Clojure libraries. I’m not entirely sure what that looks like yet. It feels like there are two distinct pieces: starting the server up with a custom classpath and Clojure plugin development. I’d like to explore different approaches to solving these problems. Specifically, custom PluginLoaders, simplifying the server launching experience, and a minimal Clojure plugin build tool.

Written on 2020-07-19