Building a proxy
Forward packets between players and an upstream server, with optional packet handlers.
Proxy accepts connections from players and opens a separate connection to the upstream server for each player. It forwards packets in both directions by name and re-encodes them for the receiving connection.
import { Proxy } from "mc-protocol";
const proxy = new Proxy({
listen: { host: "0.0.0.0", port: 25566 },
upstream: { host: "play.example.net", port: 25565 },
versions: [776], // optional; protocol numbers accepted from players
});
proxy.Client.on("ChatCommand", async (player, data, next) => {
console.log(`${player.session.username} ran /${data.command}`);
await next(data);
});
proxy.Server.on("SetPlayerInventory", (_server, data, next) => {
if (shouldHideInventory(data)) return next(null);
return next(data);
});
proxy.Client handles packets sent by players toward the upstream server. Its first argument is the player connection. proxy.Server handles packets sent by the upstream server toward a player. Its first argument is the upstream connection. Both connections expose sendPacket(name, data) for sending additional packets to that endpoint.
Call next(data) to forward a packet, optionally with changed data. Call next(null) to cancel it. If a handler does not call next, the packet is also stopped. Multiple handlers for the same packet run in registration order, so later handlers receive the data passed by the previous handler. For asynchronous work, await or return next(...).
Use proxy.Client.once(name, handler) or proxy.Server.once(name, handler) when a handler should process only the next matching packet.
The proxy forwards login, configuration, compression, and keep-alive packets to the real endpoints. proxy.server exposes the underlying Server, proxy.connections is a Map from player to upstream, and proxy.close() closes the listener and its connections.
Events
| Event | Arguments | When |
|---|---|---|
Proxy:Ready |
– | The player-facing listener is open. |
Proxy:Error |
error |
The player-facing listener failed. |
Proxy:Close |
– | The player-facing listener closed. |
connection |
player, upstream |
A player connected and an upstream Client was created. The upstream socket may not be ready yet. |
disconnect |
player, upstream |
The player socket closed. The upstream connection is destroyed. |
Upstream:Error |
player, error |
The upstream socket or packet parse failed. A socket error also destroys the player. |
Client:Error |
player, error |
The player connection failed to parse a packet. |
Packet interception is proxy.Client.on / proxy.Server.on, not these events. The same table lives on Events.
The player-facing Server accepts the protocols in versions (default: the built-in default). The upstream Client adopts the player’s negotiated protocol when the forwarded Intension is sent, so both sides of a connection use the same packet tables.
Compression is negotiated independently on each connection. By default, forwarding LoginCompression gives the player the upstream server’s threshold. The proxy decodes each incoming packet and re-encodes it with the destination connection’s compression settings, so handlers continue to receive ordinary packet data.
You can rewrite the player-facing threshold without changing the upstream connection:
proxy.Server.on("LoginCompression", (_server, _data, next) => {
return next({ threshold: 64 });
});
Dropping the announcement with next(null) leaves the player’s compression unchanged (disabled initially); the upstream connection still adopts the original threshold. The proxy’s internal server has autoInit: false, so it does not send its own announcement.