docs · authoring
HTTP servers.
rune.serve() exposes a Paper-internal HTTP server backed by the JDK's com.sun.net.httpserver. Routes register at script load. Handlers can be async and call back into Bukkit on the main thread. That's the unlock — you can build a web dashboard that operates on live world state without an IPC layer.
Hello server
The minimum is a port, a route, and a handler. The handler receives a context object with the request, the resolved params, and helpers to read the body.
const app = rune.serve({ port: 3000 });
app.get("/api/health", () => ({ status: "ok" }));
app.get("/api/players/:uuid", async (c) => {
const uuid = c.param("uuid")!;
return rune.runOnMain(() => {
const player = bukkit.Bukkit.getPlayer(uuid);
if (!player) return { error: "not_found", status: 404 };
return { uuid, name: player.getName(), world: player.getWorld().getName() };
});
});
app.start();Returning a plain object responds with 200 application/json. Returning a tuple or a shaped response object lets you control status code and headers. A thrown error becomes a 500.
Route shape
app.get(path, handler)/app.post/app.put/app.patch/app.delete— one per verb.- Path params with
:nameare matched and exposed viac.param("name"). c.reqis the underlying request (URL, headers, body stream).c.req.json()/c.req.text()read the body asynchronously.
Bukkit calls from a request
HTTP handlers run on the server's HTTP thread pool, not the Paper main thread. Bukkit isn't thread-safe; any call into the API must be wrapped in rune.runOnMain(), which schedules a callback on the main tick and resolves a promise with its return value.
app.post("/api/players/:uuid/kick", async (c) => {
const uuid = c.param("uuid")!;
const { reason } = await c.req.json<{ reason?: string }>();
return rune.runOnMain(() => {
const player = bukkit.Bukkit.getPlayer(uuid);
if (!player) return { error: "offline", status: 404 };
player.kick(reason ?? "kicked via dashboard");
return { ok: true };
});
});Authentication
The HTTP server is just a server — Rune doesn't ship an opinionated auth layer. ward issues bearer tokens from an in-game command and validates them in a middleware-like gateway pattern.
function gate<T>(
perm: string,
handler: (c: Context, session: Session) => Promise<T> | T,
) {
return async (c: Context) => {
const session = lookupBearer(c.req.headers.get("authorization"));
if (!session) return unauthorized();
if (!session.perms[perm]) return forbidden();
return handler(c, session);
};
}
// Issued by the in-game command:
@Command("ward web token")
export class WardWebToken {
@Run
run(ctx: CommandCtx) {
if (!isPlayer(ctx.sender)) return;
const uuid = String(ctx.sender.getUniqueId());
const token = issueToken(uuid, ctx.sender.getName());
ctx.sender.sendMessage(
`<click:copy_to_clipboard:'${token}'>click to copy token</click>`,
);
}
}
app.get("/api/players/:uuid", gate("ward.user.info", async (c, session) => {
// session.uuid is the authed player; c.param("uuid") is the target.
return fetchPlayer(c.param("uuid")!);
}));Serving static files and a SPA
For a dashboard you'll want to ship HTML, JS, and CSS in addition to the API. serve({ static: ... }) takes a directory and serves it with proper Content-Type headers. With vite: true, the host spawns Vite in dev mode and proxies HMR through the same port; in production it serves the built dist/ directly.
const app = rune.serve({
port: 3000,
static: {
dir: "./web/dist",
fallback: "index.html", // SPA fallback for client routing
},
vite: env.NODE_ENV === "development" && {
dir: "./web", // your vite project root
entry: "src/main.ts",
},
});
// API routes register normally — they take precedence over static.
registerWebApi(app);
app.start();The fallback option is what makes SPA routing work: any non-file URL falls back to index.html, letting your client-side router pick up the path.
Shutdown and reload
app.start() binds the port. On reload the host drains in-flight requests and closes the listener before tearing down the runtime; your start() call re-runs and rebinds. Clients reconnect on their next request. If your dashboard uses long-lived WebSockets, expect them to reconnect through reload.