Skip to content

Relay(プロキシ) ​

Relay は「サーバー + クライアント」の組み合わせで、中間者(MITM)接続 を実現するクラスです。クライアントと宛先サーバーの間に割り込み、通過するパケットを観測・改変・遮断できます。認証や暗号化の処理は Relay が面倒を見ます。

最小のサンプル ​

js
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 パケットで、ゲーム内のトースト通知(画面にポップアップ表示)を出せます。

js
relay.on('connect', player => {
  player.on('join', () => {
    player.queue('toast_request', {
      title: 'こんにちは!',
      message: 'Relay 経由の通知です'
    })
  })
})

チャット表示・効果音 ​

player.queue() は Toast やフォームだけではありません。クライアント向けの任意のパケット を送れるため、チャット欄へのメッセージ表示や効果音の再生も同様にできます。

js
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 種類から選べます。

js
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" など)
modal2 択ダイアログ(button1 / button2)"true" / "false"
custom_form入力・スイッチ・ドロップダウン等の複合フォーム["入力値",true,0] のような JSON 配列

応答はクライアント → Relay 方向の modal_form_response として serverbound イベントで受け取れます。上流サーバーはフォームのことを知らないため、必ず des.canceled = true で転送を止めてください。

js
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(引数なし)を送ります。

js
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 を下流に流すと、クライアント画面に実績解除の演出を表示させられます。

js
relay.on('join', (downstream) => {
  downstream.queue('award_achievement', { achievement_id: 0 })
})

実績 ID はクライアント側の判定をすり抜けられるわけではなく、あくまで演出として機能します。

ゲームイベントから推定する(ベストエフォート)

上り側の inventory_transaction(アイテム入手)や player_action(ブロック破壊)などのパケットから「実績が解除された可能性が高い」ことを推定し、Toast やチャットで祝福する、という実装なら可能です。ただし実際に解除されたかの保証はありません。

公開リレーの運用(メンバー向け・オンライン編) ​

「メンバー向けに公開した Relay から、特定のオンラインサーバーへ接続する」構成は、このライブラリの Relay の主な想定用途です。可能です。ただし、オンライン運用では Relay 自体が認証処理を仲介するため、その仕組みを理解しておく必要があります。

構成 ​

ポイントは、下流(クライアント→Relay)と上流(Relay→サーバー)の2回認証が走ることです。

認証の流れ ​

  1. 下流: メンバーは自分の Microsoft アカウントでサインインし、Relay がそれを検証します(offline を指定していない場合)。
  2. 上流: Relay はプレイヤーごとに新しい Client を作り、PrismarineAuth(username = プレイヤーの xuid) で XBL 認証を実行します。その結果、バックエンドのサーバーにはメンバー本人として見えます。
  3. 以後のパケットは 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) を受け取るため、メッセージを完全に差し替えられます。

js
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 も作れます(既定の動作は切断ですが、コールバックで挙動を自由に変えられます)。

実装例 ​

js
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 を継承しているため、サーバー側の圧縮オプションがそのまま使われます。

js
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 のオーバーヘッドが減ります(遅延とのトレードオフ)。

チャンク半径の上書きの実装例です。

js
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 の upstreams Map)が作られ、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 の値で常に上書きされる
js
relay.on('join', (downstream, upstream) => {
  // downstream: Relay に接続した Player
  // upstream:   バックエンドに接続済みの Client
})

Realm を経由する Relay ​

destination.realms を指定すると、宛先として Realm を使えます。実装例はリポジトリの examples/realmRelay.js を参照してください。