rune

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 :name are matched and exposed via c.param("name").
  • c.req is 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.