Relay(プロキシ)
Relay は「サーバー + クライアント」の組み合わせで、中間者(MITM)接続 を実現するクラスです。クライアントと宛先サーバーの間に割り込み、通過するパケットを観測・改変・遮断できます。認証や暗号化の処理は Relay が面倒を見ます。
最小のサンプル
const { Relay } = require('bedrock-protocol')
const relay = new Relay({
version: '1.21.50',
host: '0.0.0.0', // クライアントの受け口
port: 19132,
destination: { // 転送先サーバー
host: '127.0.0.1',
port: 19131
}
})
relay.listen() // 待ち受け開始
relay.on('connect', player => {
console.log('新しい接続:', player.connection.address)
// サーバー → クライアント方向のパケットを観測・改変
player.on('clientbound', ({ name, params }, des) => {
if (name === 'disconnect') {
params.message = 'Intercepted' // キックメッセージを書き換え
}
})
// クライアント → サーバー方向のパケットを観測・改変・遮断
player.on('serverbound', ({ name, params }, des) => {
if (name === 'text') {
params.message += `, on ${new Date().toLocaleString()}` // チャットに時刻を追記
}
if (name === 'command_request' && params.command === '/test') {
des.canceled = true // パケットを握りつぶす(転送しない)
}
})
})使い方の要点
パケットの流れ
クライアント ⇄ [Relay サーバー] ⇄ [Relay 上流クライアント] ⇄ 宛先サーバー
│
clientbound / serverbound イベントで傍受| イベント | 対象 |
|---|---|
clientbound | 宛先サーバーから クライアントへ向かう パケット |
serverbound | クライアントから 宛先サーバーへ向かう パケット |
ハンドラは (packet, des) を受け取ります。
packet.name… パケット名(例:'text')packet.params… パケット本体(ここを書き換えると改変される)des.canceled = true… そのパケットの転送をキャンセル
送信
| 操作 | コード |
|---|---|
| クライアントへ送る | player.queue(name, params) |
| 宛先サーバーへ送る | player.upstream.queue(name, params) |
接続中のプレイヤーに Toast / GUI を送る
player.queue() でクライアント側に任意のパケットを送れるため、Toast 通知やメニュー(フォーム)UI を表示できます。上流サーバーの実装には触れず、中継する Relay 側から直接クライアントへ送られます。
Toast 通知
toast_request パケットで、ゲーム内のトースト通知(画面にポップアップ表示)を出せます。
relay.on('connect', player => {
player.on('join', () => {
player.queue('toast_request', {
title: 'こんにちは!',
message: 'Relay 経由の通知です'
})
})
})チャット表示・効果音
player.queue() は Toast やフォームだけではありません。クライアント向けの任意のパケット を送れるため、チャット欄へのメッセージ表示や効果音の再生も同様にできます。
relay.on('connect', player => {
player.on('join', () => {
// チャット欄に表示。type は 'chat'・'system'・'raw' など 12 種
player.queue('text', {
type: 'system', // 'system' / 'raw' は source_name 不要で楽
needs_translation: false,
message: 'Relay からのアナウンス',
xuid: '',
platform_chat_id: '',
filtered_message: ''
})
// 効果音の再生(name はサウンド ID)
player.queue('play_sound', {
name: 'note.pling',
coordinates: { x: 0, y: 0, z: 0 }, // プレイヤー付近の座標
volume: 1.0,
pitch: 1.0
})
})
})text パケットの type 別の使い分けです。
| type | 説明 | 必要なフィールド |
|---|---|---|
'chat' | プレイヤー発言風(吹き出しに見せる) | source_name + message |
'whisper' / 'announcement' | ささやき / アナウンス風 | source_name + message |
'system' / 'raw' / 'tip' | システムメッセージ / 素の文字列(推奨) | message のみ |
'translation' / 'popup' | 翻訳キー / 画面中央ポップアップ | message(翻訳キー) + parameters |
いずれも共通で xuid・platform_chat_id・filtered_message を付ける必要があります。チャット欄にただ表示したいだけなら 'system' か 'raw' が簡単です。
フォーム(メニュー UI)
modal_form_request でフォームを表示できます。data にはフォーム定義を JSON 文字列 として渡します。type で 3 種類から選べます。
player.queue('modal_form_request', {
form_id: 1, // 応答の突き合わせに使う任意の ID
data: JSON.stringify({
type: 'form', // 'form' | 'modal' | 'custom_form'
title: 'メニュー',
content: '選んでください',
buttons: [{ text: 'ボタン 1' }, { text: 'ボタン 2' }]
})
})| type | 表示 | 応答データ(params.data は JSON 文字列) |
|---|---|---|
form | ボタンの一覧(buttons) | 押したボタンのインデックス("0" など) |
modal | 2 択ダイアログ(button1 / button2) | "true" / "false" |
custom_form | 入力・スイッチ・ドロップダウン等の複合フォーム | ["入力値",true,0] のような JSON 配列 |
応答はクライアント → Relay 方向の modal_form_response として serverbound イベントで受け取れます。上流サーバーはフォームのことを知らないため、必ず des.canceled = true で転送を止めてください。
player.on('serverbound', ({ name, params }, des) => {
if (name === 'modal_form_response') {
des.canceled = true // 上流サーバーへ転送しない(必須)
console.log('form_id:', params.form_id, '応答:', params.data)
// params.has_response_data が true のとき params.data に JSON 文字列が入る
// 例: JSON.parse(params.data)
}
})表示中のフォームを閉じるには clientbound_close_form(引数なし)を送ります。
player.queue('clientbound_close_form', {})通常サーバーの Player でも同じ
これは Relay 特有の話ではありません。通常サーバーの Player インスタンスでも player.queue('toast_request', ...) で同様に送れます。
実績(アチーブメント)の検知はできない
「プレイヤーが実績を解除した」ことを ネットワーク経由で検知することはできません。実績の判定・解除はクライアント側で行われ、Xbox / Microsoft アカウントに同期されるだけで、サーバーや Relay には一切通知されないためです。
プロトコル上は award_achievement(パケット ID 309)が存在しますが、これは Server → Client 方向専用(clientbound) であり、上り方向のパケットは存在しません。
| 事実 | 内容 |
|---|---|
| 方向 | Server → Client のみ(serverbound で観測できることはない) |
| フィールド | achievement_id(li32)のみ |
| 実際の使われ方 | バニラのサーバー・クライアントではほぼ未使用の残骸パケット |
したがって、Relay で「実績が解除された瞬間」をトリガーに何かを実行する、という実装は構造的に作れません。
Relay にできる代替案
その代わりに、次のような「実績まわりの疑似操作」は可能です。
実績の有効 / 無効を制御する
start_game の achievements_disabled フラグで、そのワールドで実績が獲得できるかを制御できます。リレーで上流の start_game を書き換えて無効化(または復活)させられます。
「解除済み」の見せかけを作る
award_achievement を下流に流すと、クライアント画面に実績解除の演出を表示させられます。
relay.on('join', (downstream) => {
downstream.queue('award_achievement', { achievement_id: 0 })
})実績 ID はクライアント側の判定をすり抜けられるわけではなく、あくまで演出として機能します。
ゲームイベントから推定する(ベストエフォート)
上り側の inventory_transaction(アイテム入手)や player_action(ブロック破壊)などのパケットから「実績が解除された可能性が高い」ことを推定し、Toast やチャットで祝福する、という実装なら可能です。ただし実際に解除されたかの保証はありません。
公開リレーの運用(メンバー向け・オンライン編)
「メンバー向けに公開した Relay から、特定のオンラインサーバーへ接続する」構成は、このライブラリの Relay の主な想定用途です。可能です。ただし、オンライン運用では Relay 自体が認証処理を仲介するため、その仕組みを理解しておく必要があります。
構成
ポイントは、下流(クライアント→Relay)と上流(Relay→サーバー)の2回認証が走ることです。
認証の流れ
- 下流: メンバーは自分の Microsoft アカウントでサインインし、Relay がそれを検証します(
offlineを指定していない場合)。 - 上流: Relay はプレイヤーごとに新しい Client を作り、
PrismarineAuth(username = プレイヤーの xuid)で XBL 認証を実行します。その結果、バックエンドのサーバーにはメンバー本人として見えます。 - 以後のパケットは Relay が中継します。メンバーはあたかも直接オンラインサーバーに接続しているかのようにプレイできます。
初回サインイン(onMsaCode)
上流の認証トークンは profilesFolder にキャッシュされます。初回のみ、メンバー本人が自分の Microsoft アカウントで一度サインインする必要があります。
onMsaCode を指定しない場合、Relay はデフォルトで次のメッセージとともに切断します。
It's your first time joining. Please sign in and reconnect to join this server:
<コードとURL>サインイン案内メッセージの差し替え
このデフォルト文言は 英語で固定され、ライブラリ側に i18n 機構はありません。ただし onMsaCode オプションを渡せば、コールバックが (code, downstreamPlayer) を受け取るため、メッセージを完全に差し替えられます。
const relay = new Relay({
...
onMsaCode: (code, player) => {
player.disconnect(`初回参加のため認証が必要です。\n\n${code.message}`)
// code.message は「コード + URL」形式
}
})code オブジェクトにはメッセージ文字列以外にも、サインイン URL やコード本体のフィールドが含まれるので、code.message を使わずに URL とコードを別々に提示することもできます。
さらに発展させると、disconnect せず接続を維持したまま modal_form_request などでコードを表示し、認証完了を待つ UI も作れます(既定の動作は切断ですが、コールバックで挙動を自由に変えられます)。
実装例
const { Relay } = require('bedrock-protocol')
const relay = new Relay({
version: '1.21.50', // 対象サーバーと合わせる
host: '0.0.0.0',
port: 19132,
destination: {
host: 'play.example.jp', // 接続先のオンラインサーバー
port: 19132
},
profilesFolder: '/etc/bedrock-relay/profiles', // セッションキャッシュ先。必須かつ書込可
// offline を指定しないこと(オンライン運用)
onMsaCode: (code) => {
console.log('サインインコード:', code) // ログや画像でメンバーに提示する
}
})
relay.listen()
relay.on('connect', player => {
console.log('新しい接続:', player.connection.address)
})versionは対象サーバーと同じバージョンを指定します。profilesFolderはリレープロセスが書き込めるパスにして、運営者以外がアクセスできない場所に置きます。offline(およびdestination.offline)を指定するとオンラインサーバーに接続できません。
通信の圧縮
Bedrock の通信はバッチ単位で圧縮できます。圧縮は接続ごとに独立しており、Relay の下流と上流では別々に扱われます。
下流(クライアント ↔ Relay)— 設定可能
Relay は Server を継承しているため、サーバー側の圧縮オプションがそのまま使われます。
const relay = new Relay({
...
compressionAlgorithm: 'snappy', // 'none' | 'deflate' | 'snappy'(デフォルト deflate)
compressionLevel: 7, // deflate の圧縮レベル 1〜9
compressionThreshold: 512 // このサイズ以下のバッチは圧縮しない
})接続時に network_settings でこの設定がクライアントへ通知され、クライアントもその方式で圧縮します。snappy は BDS でも使われる方式です。
上流(Relay ↔ バックエンド)— リレー側では指定できない
上流の Client は openUpstreamConnection で生成され、圧縮オプションは渡されていません。また Bedrock ではクライアント側の圧縮方式はサーバーが network_settings で決める仕組みのため、上流区間はバックエンドサーバーの設定に従います。リレー側から強制することはできません。
まとめると、メンバーの回線(下流)の帯域削減は Relay の設定で可能で、上流はバックエンド次第です。
帯域幅を節約する他の方法
圧縮以外にも、リレー / サーバー側で帯域幅を抑える手段があります。
- チャンクキャッシュの有効化(効果が大きい):
enableChunkCaching: trueを指定すると、クライアントがチャンクデータをキャッシュし、再訪時に通信せず再利用します(client_cache_status/client_cache_blob_status)。戻ってきたプレイヤーへの再送信が大幅に減ります。 - チャンク半径の上書き(効果が大きい):チャンク送信は帯域の大部分を占めます。
clientboundでnetwork_chunk_publisher_updateを横取りし、radiusを小さく書き換えられます。 - 不要パケットの中継抑制:下流に流す価値の低いパケットを
des.canceled = trueで落とせます(例: 遠距離のlevel_sound_event・level_event・set_entity_data・animateなど)。 - バッチ間隔の調整:
batchingIntervalを大きくすると、パケットをまとめて送る頻度が下がり、UDP のオーバーヘッドが減ります(遅延とのトレードオフ)。
チャンク半径の上書きの実装例です。
relay.on('connect', player => {
// プレイヤーの現在位置を追跡(遠距離パケットの判定用)
const playerPos = { x: 0, y: 0, z: 0 }
player.on('serverbound', ({ name, params }) => {
if (name === 'move_player') {
playerPos.x = params.position.x
playerPos.y = params.position.y
playerPos.z = params.position.z
}
})
player.on('clientbound', ({ name, params }, des) => {
// バックエンドが通知する配信半径を 8 チャンクに制限
if (name === 'network_chunk_publisher_update' && params.radius > 8) {
params.radius = 8
}
// 例: プレイヤーから 32 ブロック以上離れた効果音を落とす
// (level_sound_event の position は vec3f、is_global は全員向けフラグ)
if (name === 'level_sound_event' && !params.is_global) {
const dx = params.position.x - playerPos.x
const dz = params.position.z - playerPos.z
if (dx * dx + dz * dz > 32 * 32) des.canceled = true
}
})
})チャンク半径の縮小はプレイヤーの視認距離が狭くなるトレードオフがあるので、ほどほどが肝心です。
複数メンバー同時接続と注意点
- 同時接続はデフォルトで問題ありません。プレイヤーごとに独立した上流 Client(
src/relay.jsのupstreamsMap)が作られ、1 人分の切断が他に影響しません。制限したい場合はforceSingle: trueを使います。 - オンライン運用では各メンバーを自分のアカウントで接続させるため、プレイヤー間のセッションはお互いに干渉しません(同じ Microsoft アカウントを複数人が共用すると、XBL/サーバー側の同時ログイン制限に当たる可能性があります)。
- 運営者はメンバーの Microsoft セッションキャッシュを保持し、全パケットを中継(観測)できます。事実上の MITM であるため、信頼できるメンバーに限定するのが前提です。
- トークン失効時は再度
onMsaCodeが発火するため、自動的に再認証を促されます。
主なオプション
| オプション | 説明 |
|---|---|
destination.host / port | 転送先サーバー(必須) |
destination.offline | 上流接続で認証をスキップするか |
offline | 接続してきたクライアントの認証をスキップ(false 指定時はログイン要求) |
version | 動作させるバージョン |
logging | パケットログの出力 |
enableChunkCaching | チャンクキャッシュを有効化(デフォルト false) |
forceSingle | 同時接続を 1 クライアントに制限(デフォルト false) |
omitParseErrors | パースエラー時に切断せずパケットを破棄(デフォルト false) |
onMsaCode | 上流参加用の認証トークンが必要になったときに呼ばれるコールバック。未指定の場合、クライアントはログインプロンプト付きで切断されます |
認証について
offline を指定しない場合、Relay に接続してきたクライアントに対してログイン(Xbox Live 認証)を求めます。上流サーバーがオンラインモードの場合、その認証情報がそのまま転送に使われます。
上級者向けの拡張ポイント(コードから)
src/relay.js を読むと、ドキュメント外のカスタマイズポイントが見つかります。
| オプション / 機能 | 説明 |
|---|---|
relayPlayer | 独自の RelayPlayer 派生クラスを注入できる(new Relay({ relayPlayer: MyPlayer }))。パケット中継ロジックを丸ごと差し替え可能 |
noLoginForward(Client 側フラグ) | 下流クライアントの skinData を上流ログインに転送しない |
'join' イベント | Relay 自体が (下流 Player, 上流 Client) の 2 引数付きで発火する。両側を直接操作したい場合に便利 |
| 双方向キュー | 上流 / 下流のどちらかが未接続でも、到着したパケットは自動でキューイングされ、接続後にフラッシュされる |
client_cache_status 強制制御 | チャンクキャッシュ要求は enableChunkCaching の値で常に上書きされる |
relay.on('join', (downstream, upstream) => {
// downstream: Relay に接続した Player
// upstream: バックエンドに接続済みの Client
})Realm を経由する Relay
destination.realms を指定すると、宛先として Realm を使えます。実装例はリポジトリの examples/realmRelay.js を参照してください。