ダイレクト ソケット

Demián Renzulli
Demián Renzulli
Andrew Rayskiy
Andrew Rayskiy
Vlad Krot
Vlad Krot

標準的なウェブ アプリケーションは通常、HTTP などの特定の通信プロトコルや、WebSocketWebRTC などの API に制限されます。これらの機能は強力ですが、不正使用を防ぐために厳しく制限されるように設計されています。生の TCP または UDP 接続を確立できないため、独自の非ウェブ プロトコルを使用するレガシー システムやハードウェア デバイスとウェブアプリが通信する機能が制限されます。たとえば、ウェブベースの SSH クライアントを構築したり、ローカル プリンタに接続したり、IoT デバイスのフリートを管理したりできます。従来は、ブラウザ プラグインまたはネイティブ ヘルパー アプリケーションが必要でした。

Direct Sockets API は、独立したウェブアプリ(IWA)がリレーサーバーなしで直接 TCP 接続と UDP 接続を確立できるようにすることで、この制限に対処します。IWA では、厳格なコンテンツ セキュリティ ポリシー(CSP)やクロスオリジン分離などの追加のセキュリティ対策により、この API を安全に公開できます。

ユースケース

標準の WebSocket よりも Direct Sockets を使用した方がよいのはどのような場合ですか?

  • IoT とスマート デバイス: HTTP ではなく、生の TCP/UDP を使用するハードウェアとの通信。
  • レガシー システム: 古いメールサーバー(SMTP/IMAP)、IRC チャットサーバー、プリンタへの接続。
  • リモート デスクトップとターミナル: SSH、Telnet、RDP クライアントの実装。
  • P2P システム: 分散ハッシュテーブル(DHT)または復元力のあるコラボレーション ツール(IPFS など)の実装。
  • メディア ブロードキャスト: UDP を活用して、複数のエンドポイントにコンテンツを一度にストリーミング(マルチキャスト)し、小売店のキオスクのネットワーク全体で動画再生を調整するなどのユースケースを実現します。
  • サーバーとリスナーの機能: TCPServerSocket またはバインドされた UDPSocket を使用して、受信 TCP 接続または UDP データグラムの受信エンドポイントとして機能するように IWA を構成します。

Direct Sockets の前提条件

Direct Sockets を使用する前に、機能する IWA を設定する必要があります。その後、Direct Sockets をページに統合できます。

権限ポリシーを追加する

Direct Sockets を使用するには、IWA マニフェストで permissions_policy オブジェクトを構成する必要があります。API を明示的に有効にするには、direct-sockets キーを追加する必要があります。また、cross-origin-isolated キーも指定する必要があります。このキーは Direct Sockets に固有のものではありませんが、すべての IWA で必要であり、ドキュメントがクロスオリジン分離を必要とする API にアクセスできるかどうかを決定します。

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

direct-sockets キーは、new TCPSocket(...)new TCPServerSocket(...)new UDPSocket(...) への呼び出しが許可されるかどうかを決定します。このポリシーが設定されていない場合、これらのコンストラクタは NotAllowedError で直ちに拒否します。

TCPSocket を実装する

アプリは、TCPSocket インスタンスを作成して TCP 接続をリクエストできます。

接続を開く

接続を開くには、new 演算子と開いた Promise の await を使用します。

TCPSocket コンストラクタは、指定された remoteAddressremotePort を使用して接続を開始します。

const remoteAddress = 'example.com';
const remotePort = 7;

// Configure options like keepAlive or buffering
const options = {
  keepAlive: true,
  keepAliveDelay: 720000
};

let tcpSocket = new TCPSocket(remoteAddress, remotePort, options);

// Wait for the connection to be established
let { readable, writable } = await tcpSocket.opened;

省略可能な構成オブジェクトを使用すると、ネットワークをきめ細かく制御できます。この特定のケースでは、非アクティブ期間中に接続を維持するために keepAliveDelay が 720, 000 ミリ秒に設定されています。デベロッパーは、noDelay などの他のプロパティもここで構成できます。noDelay は、Nagle アルゴリズムを無効にして、システムが小さなパケットをバッチ処理しないようにします。これにより、レイテンシが短縮される可能性があります。sendBufferSizereceiveBufferSize は、スループットを管理します。

前のスニペットの最後の部分では、コードは開かれた Promise を待機します。この Promise は、ハンドシェイクが完了したときにのみ解決され、データ送信に必要な読み取り可能ストリームと書き込み可能ストリームを含む TCPSocketOpenInfo オブジェクトを返します。

読み取りと書き込み

ソケットが開いたら、標準の Streams API インターフェースを使用して操作します。

  • 書き込み: 書き込み可能なストリームは BufferSourceArrayBuffer など)を受け取ります。
  • 読み取り: 読み取り可能なストリームは Uint8Array データを生成します。
// Writing data
const writer = writable.getWriter();
const encoder = new TextEncoder();
await writer.write(encoder.encode("Hello Server"));

// Call when done
writer.releaseLock();

// Reading data
const reader = readable.getReader();
const { value, done } = await reader.read();
if (!done) {
    const decoder = new TextDecoder();
    console.log("Received:", decoder.decode(value));
}

// Call when done
reader.releaseLock();

BYOB による読み取りの最適化

メモリ割り当ての管理が重要な高性能アプリケーションの場合、API は「Bring Your Own Buffer」(BYOB)読み取りをサポートします。ブラウザが受信したデータのチャンクごとに新しいバッファを割り当てるのではなく、事前に割り当てられたバッファをリーダーに渡すことができます。これにより、既存のメモリにデータを直接書き込むことで、ガベージ コレクションのオーバーヘッドが削減されます。

// 1. Get a BYOB reader explicitly
const reader = readable.getReader({ mode: 'byob' });

// 2. Allocate a reusable buffer (e.g., 4KB)
let buffer = new Uint8Array(4096);

// 3. Read directly into the existing buffer
const { value, done } = await reader.read(buffer);

if (!done) {
  // 'value' is a view of the data written directly into your buffer
  console.log("Bytes received:", value.byteLength);
}

reader.releaseLock();

UDPSocket を実装する

UDPSocket クラスを使用すると、UDP 通信が可能になります。オプションの構成方法に応じて、2 つの異なるモードで動作します。

接続モード

このモードでは、ソケットは単一の特定の宛先と通信します。これは、標準のクライアント サーバー タスクに役立ちます。

// Connect to a specific remote host
let udpSocket = new UDPSocket({
    remoteAddress: 'example.com',
    remotePort: 7 });

let { readable, writable } = await udpSocket.opened;

バウンドモード

このモードでは、ソケットはローカル IP エンドポイントにバインドされます。任意のソースからデータグラムを受信し、任意の宛先に送信できます。これは、ローカル ディスカバリ プロトコルやサーバーのような動作によく使用されます。

// Bind to all interfaces (IPv6)
let udpSocket = new UDPSocket({
    localAddress: '::'
    // omitting localPort lets the OS pick one
});

// localPort will tell you the OS-selected port.
let { readable, writable, localPort } = await udpSocket.opened;

UDP メッセージを処理する

バイトの TCP ストリームとは異なり、UDP ストリームはデータとリモート アドレス情報を含む UDPMessage オブジェクトを扱います。次のコードは、「バウンドモード」で UDPSocket を使用する場合の入出力オペレーションの処理方法を示しています。

// Writing (Bound Mode requires specifying destination)
const writer = writable.getWriter();
await writer.write({
    data: new TextEncoder().encode("Ping"),
    remoteAddress: '192.168.1.50',
    remotePort: 8080
});

// Reading
const reader = readable.getReader();
const { value } = await reader.read();
// value contains: { data, remoteAddress, remotePort }
console.log(`Received from ${value.remoteAddress}:`, value.data);

ソケットが特定のピアにロックされる「接続モード」とは異なり、バインドモードでは、ソケットは任意の宛先と通信できます。したがって、書き込み可能なストリームにデータを書き込むときは、各パケットの remoteAddressremotePort を明示的に指定する UDPMessage オブジェクトを渡して、特定のデータグラムをルーティングする場所をソケットに正確に指示する必要があります。同様に、読み取り可能なストリームから読み取る場合、戻り値にはデータ ペイロードだけでなく送信者の remoteAddressremotePort も含まれるため、アプリケーションはすべての受信パケットの送信元を特定できます。

注: 「接続モード」で UDPSocket を使用すると、ソケットは特定のピアに効果的にロックされ、I/O プロセスが簡素化されます。このモードでは、宛先がすでに固定されているため、書き込み時に remoteAddress プロパティと remotePort プロパティは実質的に no-op になります。同様に、メッセージを読み取る場合、ソースは接続されたピアであることが保証されているため、これらのプロパティは null を返します。

マルチキャスト サポート

複数のキオスクでの動画再生の同期やローカル デバイス検出(mDNS など)の実装などのユースケースでは、Direct Sockets はマルチキャスト UDP をサポートしています。これにより、メッセージを「グループ」アドレスに送信して、特定の単一のピアではなく、ネットワーク上のすべてのサブスクライバーが受信できるようになります。

マルチキャスト権限

マルチキャスト機能を使用するには、IWA マニフェストに特定の direct-sockets-multicast 権限を追加する必要があります。これは標準の直接ソケット権限とは異なり、マルチキャストはプライベート ネットワークでのみ使用されるため、この権限が必要です。

{
  "permissions_policy": {
    "direct-sockets": ["self"],
    "direct-sockets-multicast": ["self"],
    "direct-sockets-private": ["self"],
    "cross-origin-isolated": ["self"]
  }
}

マルチキャスト データグラムを送信する

マルチキャスト グループへの送信は、標準の UDP「接続モード」と非常によく似ていますが、パケットの動作を制御するための特定のオプションが追加されています。

const MULTICAST_GROUP = '239.0.0.1';
const PORT = 12345;

const socket = new UDPSocket({
  remoteAddress: MULTICAST_GROUP,
  remotePort: PORT,
  // Time To Live: How many router hops the packet can survive (default: 1)
  multicastTimeToLive: 5,
  // Loopback: Whether to receive your own packets (default: true)
  multicastLoopback: true
});

const { writable } = await socket.opened;
// Write to the stream as usual...

マルチキャスト データグラムを受信する

マルチキャスト トラフィックを受信するには、「バウンドモード」で UDPSocket を開き(通常は 0.0.0.0 または :: にバインド)、MulticastController を使用して特定のグループに参加する必要があります。また、multicastAllowAddressSharing オプション(Unix の SO_REUSEADDR と同様)を使用することもできます。これは、同じデバイス上の複数のアプリが同じポートをリッスンする必要があるデバイス検出プロトコルに不可欠です。

const socket = new UDPSocket({
  localAddress: '0.0.0.0', // Listen on all interfaces
  localPort: 12345,
  multicastAllowAddressSharing: true // Allow multiple applications to bind to the same address / port pair.
});

// The open info contains the MulticastController
const { readable, multicastController } = await socket.opened;

// Join the group to start receiving packets
await multicastController.joinGroup('239.0.0.1');

const reader = readable.getReader();

// Read the stream...
const { value } = await reader.read();
console.log(`Received multicast from ${value.remoteAddress}`);

// When finished, you can leave the group (this is an optional, but recommended practice)
await multicastController.leaveGroup('239.0.0.1');

サーバーを作成する

この API は、受信 TCP 接続を受け入れる TCPServerSocket もサポートしているため、IWA をローカル サーバーとして機能させることができます。次のコードは、TCPServerSocket インターフェースを使用して TCP サーバーを確立する方法を示しています。

// Listen on all interfaces (IPv6)
let tcpServerSocket = new TCPServerSocket('::');

// Accept connections via the readable stream
let { readable } = await tcpServerSocket.opened;
let reader = readable.getReader();

// Wait for a client to connect
let { value: clientSocket } = await reader.read();

// 'clientSocket' is a standard TCPSocket you can now read/write to

'::' アドレスでクラスをインスタンス化すると、サーバーは使用可能なすべての IPv6 ネットワーク インターフェースにバインドされ、受信試行をリッスンします。従来のコールバック ベースのサーバー API とは異なり、この API はウェブの Streams API パターンを利用しています。受信接続は ReadableStream として配信されます。reader.read() を呼び出すと、アプリケーションはキューからの次の接続を待機して受け入れ、その特定のクライアントとの双方向通信の準備が整った完全に機能する TCPSocket インスタンスである値に解決されます。

Chrome DevTools で Direct Sockets をデバッグする

Chrome 138 以降では、Chrome DevTools の [ネットワーク] パネル内で Direct Sockets トラフィックを直接デバッグできるため、外部パケット スニファは不要になります。このツールを使用すると、標準の HTTP リクエストとともに、TCPSocket 接続と UDPSocket トラフィック(バインド モードと接続モードの両方)をモニタリングできます。

アプリのネットワーク アクティビティを検査するには:

  1. Chrome DevTools で [ネットワーク] パネルを開きます。
  2. リクエスト テーブルでソケット接続を見つけて選択します。
  3. [メッセージ] タブを開いて、送受信されたすべてのデータのログを表示します。

DevTools の [メッセージ] タブのデータ。

このビューには 16 進ビューアが用意されており、TCP メッセージと UDP メッセージの未加工のバイナリ ペイロードを検査して、プロトコルの実装がバイト単位で完璧であることを確認できます。

デモ

IWA Kitchen Sink には、複数のタブを備えたアプリが含まれています。各タブでは、Direct Sockets や Controlled Frame など、さまざまな IWA API がデモされています。

また、telnet クライアントのデモには、ユーザーがインタラクティブ ターミナルを介して TCP/IP サーバーに接続できる分離されたウェブアプリが含まれています。つまり、Telnet クライアントです。

まとめ

Direct Sockets API は、ウェブ アプリケーションがネイティブ ラッパーなしではサポートできなかった未加工のネットワーク プロトコルを処理できるようにすることで、重要な機能のギャップを埋めます。これは単なるクライアント接続を超えたものです。TCPServerSocket を使用すると、アプリケーションは着信接続をリッスンできます。また、UDPSocket はピアツーピア通信とローカル ネットワーク検出の両方で柔軟なモードを提供します。

これらの未加工の TCP 機能と UDP 機能を最新の Streams API を通じて公開することで、SSH、RDP、カスタム IoT 標準などのレガシー プロトコルのフル機能実装を JavaScript で直接構築できるようになりました。この API は低レベルのネットワーク アクセスを許可するため、セキュリティ上の重大な影響があります。そのため、独立したウェブアプリ(IWA)に制限され、厳格なセキュリティ ポリシーを適用する信頼できる明示的にインストールされたアプリケーションにのみ権限が付与されるようになっています。このバランスにより、ユーザーがウェブ プラットフォームに期待する安全性を維持しながら、デバイス中心の強力なアプリケーションを構築できます。

リソース