MetalAPI
XAU3,997.89−1.54%XAG53.936−1.51%XPT1,554.47−0.83%XPD1,047.03−0.44%XCU6.5080+2.29%WTI103.27+3.82%BRENT111.70−1.30%EUR1.120400−0.01%GBP1.337500−0.01%JPY0.0063761+0.14%CHF1.213445−0.25%TRY0.020125−0.12%BTC80,329.75−0.55%ETH2,329.36−1.13%
すべての記事
ストリーミングJavaScriptチュートリアル

Server-Sent Eventsで金属価格をリアルタイムにストリーミングする

Server-Sent Eventsで金・銀のリアルタイム価格を受信する方法を解説。/streamエンドポイントとイベント、EventSourceとNode.jsでの実装、再接続、クォータ、ライブティッカーまで。

執筆:MetalAPI Team··10分で読めます

ストリーミングか、ポーリングか

ポーリングは、タイマーで定期的に価格を問い合わせる方式です。ストリーミングは1本のHTTP接続を開いたままにし、新しい価格が公開されるたびにサーバーからプッシュしてもらう方式です。MetalAPIのストリームはServer-Sent Events(SSE)を使用します。これはtext/event-streamコンテンツタイプの通常のHTTPレスポンスで、接続を開いたまま名前付きのイベントを配信します。WebSocketライブラリは不要で、ほとんどのプロキシを通過でき、ブラウザはEventSourceでネイティブにサポートしています。

ストリーミングで得をするかどうかは、プランの更新間隔によって決まります。ストリームは接続時に1リクエスト、接続している間は価格の変動頻度にかかわらず1分ごとに1リクエストを消費します。ポーリングは呼び出しごとに1リクエストです。31日ある月に、1つのクライアントが24時間価格を監視する場合:

プラン更新間隔その間隔でポーリングストリーム1本を常時接続
Pro1分44,640リクエスト約45,400リクエスト
Ultra、Business15秒178,560リクエスト約45,400リクエスト
Enterprise5秒535,680リクエスト約45,400リクエスト

ストリームの数値には、後述する1時間ごとの再接続で消費される1リクエストも含まれています。Proでは、ストリーミングと1分ごとのポーリングのコストはほぼ同じです。ストリームの利点は、調整の必要なポーリングループを書かなくても、新しい価格が公開されるとすぐに届くことです。Ultra、Business、Enterpriseでは、最高頻度でポーリングするよりもストリームのほうがはるかに安く済みます。

ページ表示ごとに一度だけ正しい価格があればよい場合や、夜間のバッチ処理、しきい値のチェックには、ストリーミングは適していません。最初の2つにはキャッシュした/latestの呼び出しを、3つ目にはWebhookを使った価格アラートを使ってください。

/streamエンドポイント

http
GET https://metalapi.com/api/v1/stream?base=USD&currencies=XAU,XAG,XPT,XPD

ストリームは、/latestと同じbase、currencies(またはsymbols)、unitパラメーター(latestエンドポイントのドキュメントを参照)と、同じ認証方式を受け付けます。認証には、クエリ文字列のapi_key、Authorization: Bearerヘッダー、X-API-Keyヘッダーのいずれかを使います。パラメーターはストリームを開く前に検証されるため、不明なシンボルや単位を指定すると、空のストリームではなく通常のJSONエラーが返されます。

プランストリームでの価格更新月間リクエスト数
Pro60秒ごと100,000
Ultra15秒ごと500,000
Business15秒ごと500,000
Enterprise5秒ごと1,500,000

下位のプランでは、リアルタイムストリーミングはProプラン以上で利用できる旨のメッセージとともに、エラー106(HTTP 403)が返されます。各接続には次の制限があります:

  • 同時ストリームはAPIキー1つにつき最大5本。 6本目の接続は104(HTTP 429)で拒否されます。
  • 接続は最長60分。 1時間経つとサーバーがreconnectイベントを送ってストリームを閉じ、クライアントが再接続します。
  • キーは1分ごとに再確認されます。 キーが無効化されていたり、月間クォータを使い切っていたりすると、サーバーはerrorイベントを送ってストリームを閉じます。
  • 接続は1分あたりのレート制限にカウントされます。 ほかのリクエストと同じ扱いのため、高速な再接続ループに陥ったクライアントには104エラーが返されるようになります。

サーバーから送られるデータ

ストリームの冒頭を生のテキストで示すと、次のようになります(価格は例です):

text/event-stream
retry: 5000
event: prices
data: {"timestamp":1792834892,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002501319,"USDXAU":3997.89,"XAG":0.0185392857,"USDXAG":53.94}}

: ping 1792834907000

event: prices
data: {"timestamp":1792834952,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002500812764,"USDXAU":3998.7,"XAG":0.01852194851,"USDXAG":53.99}}
イベントデータ対応
prices完全なスナップショット:timestamp、date、base、rates。単位がトロイオンス以外の場合はunitも含む表示中の価格を置き換える
reconnect{"reason":"max connection time reached"}何もしない。ストリームが閉じたら再接続する
errorcode、type、info。コードはREST APIと共通コードに応じて停止するか、間隔を空けて再試行する
: ping …15秒ごとに送られるコメント行パーサーは無視する。接続が生きている証拠として使う

クライアントのコードを左右する性質が3つあります。すべてのpricesイベントは、リクエストしたシンボルの完全なスナップショットであり、差分ではありません。そのため、再接続したクライアントは最初のイベントを受け取った時点で最新の状態に戻ります。最初のイベントは接続後すぐに届きます。その後は、レートが変わったときだけイベントが送られます。サーバーはプランの間隔でチェックし、何も動いていなければ何も送りません。週末の金属ではこれが普通です。ハートビートを伴う沈黙は問題ありませんが、ハートビートすら届かない沈黙は接続が切れていることを意味します。

イベントにはidが付かないため、Last-Event-IDは使われません。各スナップショットがそれ以前のすべてを置き換えるので、再送すべきものはありません。ratesのキーはRESTと同じ形式です。USDXAUは金1トロイオンスの米ドル建て価格で、XAUはその逆数です。

ブラウザでEventSourceを使う

EventSourceは、接続、パース、再接続をすべて代わりに処理してくれます。カスタムヘッダーは送れないため、キーはクエリ文字列に含めます。ドメイン制限付きキーを使ってください。ブラウザはリクエストにOriginヘッダーを付けて送信するため、制限付きキーは、ほかのサイトから開かれたストリームをエラー107で拒否します。

ブラウザ
// ドメイン制限付きキーを使うこと。ページを開いた人なら誰でもキーを見られます。
const url = "https://metalapi.com/api/v1/stream?api_key=YOUR_RESTRICTED_KEY&base=EUR&currencies=XAU,XAG&unit=gram";
const FATAL = [101, 102, 103, 105, 106, 107];
const es = new EventSource(url);

es.addEventListener("prices", (e) => {
  const { timestamp, base, rates } = JSON.parse(e.data);
  document.querySelector("#gold").textContent = rates[`${base}XAU`].toFixed(2);
  document.querySelector("#silver").textContent = rates[`${base}XAG`].toFixed(3);
  document.querySelector("#updated").textContent = new Date(timestamp * 1000).toLocaleTimeString();
});

es.addEventListener("error", (e) => {
  if (e instanceof MessageEvent && e.data) {
    // ストリームを閉じる前にMetalAPIが送信:{"code":105,"type":"quota_exceeded",...}
    const err = JSON.parse(e.data);
    console.warn("MetalAPI stream error", err.code, err.info);
    if (FATAL.includes(err.code)) es.close(); // 再接続しても再び失敗する
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // それ以外は接続が切れただけなので、ブラウザが自動で再接続する。
});

errorリスナーは2種類のものを受け取ります。接続の問題は単なるEventとして届き、EventSourceはサーバーのretry行で指定された5秒後に自動で再接続します。MetalAPIが送るエラーは、dataにJSONのエラーを持つMessageEventとして届き、その直後にサーバーがストリームを閉じます。再試行しても解決しないコードの場合はclose()を呼んでください。そうしないと、ブラウザは再接続と拒否を繰り返し続けます。

最初のレスポンスがすでにHTTPエラーの場合(たとえばプランにストリーミングが含まれていないための106)、EventSourceはレスポンス本文を公開せず、再試行もしません。readyStateはCLOSEDになります。原因を調べたいときは、無料のusageエンドポイントでキーのプランを確認できます。

ブラウザから直接ストリームを開く方法は、利用者が数人程度の社内ダッシュボードに向いています。開いているタブはそれぞれが独立したストリームなので、1分ごとに1リクエストを消費し、キーあたり5本という同時ストリームの上限にもカウントされます。公開ページでは、後述のティッカーのように、サーバーで1本のストリームを動かして多数のブラウザに配信してください。

Node.jsでfetchを使う

サーバーではキーをヘッダーに入れられ、再接続も細かく制御できます。Node.js 18以降には、ストリーミングのレスポンス本文に対応したfetchが含まれているため、依存関係なしで完全なクライアントを1つのモジュールに収められます:

metal-stream.ts
// metal-stream.ts — Node.js 18以降、依存関係なし
export interface Prices {
  timestamp: number;
  date: string;
  base: string;
  unit?: string;
  rates: Record<string, number>;
}

const STREAM_URL = "https://metalapi.com/api/v1/stream?base=USD&currencies=XAU,XAG,XPT,XPD";
const FATAL = new Set([101, 102, 103, 105, 106, 107]); // 再試行しても解決しない
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

class FatalStreamError extends Error {}

export async function streamPrices(onPrices: (p: Prices) => void): Promise<never> {
  let retryMs = 5_000; // サーバーは `retry: 5000` を送る
  let failures = 0;
  for (;;) {
    const ctrl = new AbortController();
    let watchdog: ReturnType<typeof setTimeout> | undefined;
    const kick = () => {
      clearTimeout(watchdog);
      watchdog = setTimeout(() => ctrl.abort(), 45_000); // ハートビート3回分の欠落
    };
    try {
      const res = await fetch(STREAM_URL, {
        headers: { Authorization: `Bearer ${process.env.METALAPI_KEY}`, Accept: "text/event-stream" },
        signal: ctrl.signal,
      });
      if (!res.ok || !res.body) {
        const body = await res.json().catch(() => null);
        const code = body?.error?.code;
        throw FATAL.has(code) ? new FatalStreamError(`${code}: ${body.error.info}`) : new Error(`HTTP ${res.status}`);
      }
      kick();
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        kick();
        buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
        let end: number;
        while ((end = buffer.indexOf("\n\n")) !== -1) {
          const block = buffer.slice(0, end);
          buffer = buffer.slice(end + 2);
          let event = "message";
          const data: string[] = [];
          for (const line of block.split("\n")) {
            if (!line || line.startsWith(":")) continue; // ハートビートのコメント
            const i = line.indexOf(":");
            const field = i === -1 ? line : line.slice(0, i);
            const val = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
            if (field === "event") event = val;
            else if (field === "data") data.push(val);
            else if (field === "retry" && /^\d+$/.test(val)) retryMs = Number(val);
          }
          if (event === "prices") {
            failures = 0;
            onPrices(JSON.parse(data.join("\n")));
          } else if (event === "error") {
            const err = JSON.parse(data.join("\n"));
            if (FATAL.has(err.code)) throw new FatalStreamError(`${err.code}: ${err.info}`);
          }
          // `reconnect`:次にサーバーが接続を閉じ、ループが再接続する。
        }
      }
    } catch (err) {
      if (err instanceof FatalStreamError) throw err;
      failures++;
      console.warn("stream interrupted:", (err as Error).message);
    } finally {
      clearTimeout(watchdog);
    }
    // 正常に閉じた場合は5秒後、失敗が続く間は最大1分まで間隔を延ばす。
    const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
    await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
  }
}

あとは任意のスクリプトやサービスから呼び出すだけです:

watch.ts
import { streamPrices } from "./metal-stream";

streamPrices((p) => {
  console.log(new Date(p.timestamp * 1000).toISOString(), "gold", p.rates.USDXAU, "silver", p.rates.USDXAG);
}).catch((err) => {
  console.error("Stream stopped:", err.message); // FATALなコード:キー、プラン、クォータを確認する
  process.exit(1);
});

このパーサーはSSEの形式に従っています。イベントは空行で区切られ、event:がイベント名を表し、data:の行は改行で連結され、コロンで始まる行はコメントです。リクエストヘッダーを設定できるSSEクライアントライブラリであれば、次のセクションで説明する再接続のルールを適用する限り、どれを使ってもかまいません。

再接続と障害への対処

上のモジュールは、どのストリームクライアントにも当てはまる、いくつかのルールに従っています:

状況シグナル対応
1時間ごとのローテーションreconnectイベントの後にストリームが終了サーバーのretryで指定された待ち時間(5秒)の後に再接続
ネットワークの切断読み取りエラー、またはreconnectなしでストリームが終了指数バックオフとジッターを使い、最大60秒間隔で再接続
無言の接続45秒間データもハートビートもない中断して再接続(ハートビートを3回逃している)
一時的な拒否104、500、503間隔を空けて再試行
恒久的な拒否101、102、103、105、106、107停止して担当者に通知

ウォッチドッグは見た目以上に重要です。NATゲートウェイやロードバランサーの背後などで、TCPリセットなしに切れた接続は、OSが気づくまで数分間、開いたままに見えることがあります。ハートビートが15秒ごとに届くので、クライアントは45秒以内にこれを検知できます。

再接続ループには注意してください。接続に成功するたびに1リクエストを消費し、試行のたびに1分あたりのレート制限にカウントされます。何かが失敗し続けるときは、指数バックオフで被害を抑えられます。

クォータ切れ(105)には専用のアラートを用意しておく価値があります。ストリームは次の1分ごとのチェックでこれをerrorイベントとして通知します。毎月1日(UTC)にクォータがリセットされるか、プランをアップグレードするまでは、再接続しても解決しません。

ライブティッカーを作る

公開の価格ティッカーで、訪問者ごとにMetalAPIのストリームを開くべきではありません。代わりに、小さなリレーがサーバー上で1本のストリームを保持し、独自のSSEエンドポイントを通じて各スナップショットをすべてのブラウザにプッシュします。こうすれば使用量はリレーの数で決まり、訪問者数には左右されません。キーがサーバーの外に出ることもありません:

relay.ts
// relay.ts — サーバー上に1本のMetalAPIストリーム、ブラウザはいくつでも。
import { readFileSync } from "node:fs";
import http from "node:http";
import { streamPrices } from "./metal-stream";

const page = readFileSync(new URL("./ticker.html", import.meta.url));
const clients = new Set<http.ServerResponse>();
let latest: string | null = null;

const broadcast = (chunk: string) => {
  for (const res of clients) res.write(chunk);
};

streamPrices((p) => {
  latest = JSON.stringify({ timestamp: p.timestamp, base: p.base, rates: p.rates });
  broadcast(`event: prices\ndata: ${latest}\n\n`);
}).catch((err) => {
  console.error("upstream stream stopped:", err.message);
  process.exit(1);
});

setInterval(() => broadcast(": ping\n\n"), 15_000);

http.createServer((req, res) => {
  if (req.url === "/") return void res.writeHead(200, { "Content-Type": "text/html" }).end(page);
  if (req.url !== "/prices") return void res.writeHead(404).end();
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-store",
    "X-Accel-Buffering": "no",
  });
  res.write("retry: 3000\n\n");
  if (latest) res.write(`event: prices\ndata: ${latest}\n\n`); // 新しい閲覧者にもすぐに価格を送る
  clients.add(res);
  req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));

リレーは、新しい閲覧者にすぐ最新のスナップショットを送り、タイムスタンプ、基準通貨、レートだけを中継し、独自のハートビートも送信します。ページ側は、ティッカーを描画し、前回の更新からの値動きに応じて各価格を色分けする数行のコードがあれば十分です:

ticker.html
<!doctype html>
<meta charset="utf-8">
<title>Live metal prices</title>
<style>
  .ticker { display: flex; flex-wrap: wrap; gap: 1.5rem; font: 15px/1.4 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  .up { color: #15803d; }
  .down { color: #dc2626; }
</style>

<div id="ticker" class="ticker">Loading prices…</div>

<script>
  const NAMES = { XAU: "Gold", XAG: "Silver", XPT: "Platinum", XPD: "Palladium" };
  const previous = {};
  const ticker = document.getElementById("ticker");

  new EventSource("/prices").addEventListener("prices", (e) => {
    const { base, rates, timestamp } = JSON.parse(e.data);
    const money = new Intl.NumberFormat(undefined, { style: "currency", currency: base });
    ticker.replaceChildren(
      ...Object.entries(NAMES).map(([code, name]) => {
        const price = rates[base + code];
        const el = document.createElement("span");
        if (previous[code] !== undefined && price !== previous[code]) {
          el.className = price > previous[code] ? "up" : "down";
        }
        previous[code] = price;
        el.textContent = `${name} ${money.format(price)}`;
        return el;
      }),
    );
    ticker.title = `Prices as of ${new Date(timestamp * 1000).toLocaleString()}`;
  });
</script>

METALAPI_KEY=mk_live_... npx tsx relay.tsで起動し、http://localhost:3000を開きます。ティッカーは各価格を直前のイベントと比較します。代わりに1日の変動を表示したい場合は、OHLCエンドポイントから当日の始値を一度だけ取得し、新しい価格をそれぞれ始値と比較してください。

本番運用のポイント

  • プロキシのバッファリングを無効にする。 MetalAPIはX-Accel-Buffering: noとCache-Control: no-transformを送信します。リレーの前段にnginxなどのプロキシがある場合は、SSEのルートでレスポンスのバッファリングを無効にしてください(nginxではproxy_buffering off;)。そうしないと、イベントがまとめて届くようになります。
  • イベントストリームを圧縮しない。 圧縮ミドルウェアは、バッファが埋まるまで小さなチャンクを保留することがあります。text/event-streamのレスポンスは圧縮の対象から外してください。
  • リレーは常駐プロセスとして動かす。 サーバーレス関数には通常、実行時間の制限があるため、24時間ストリームを開き続ける用途には向きません。
  • データの時刻を表示する。 ブラウザが受信した時刻ではなく、イベントのtimestampを表示してください。そうすれば閲覧者は、週末の静かな相場とフィードの停止を見分けられます。
  • スクリーンリーダーに配慮する。 頻繁に更新されるティッカーをaria-live領域に置かないでください。更新のたびに読み上げられると煩わしくなります。
  • クォータを見積もる。 常時接続のストリーム1本で月に約45,400リクエストを使うため、Proプランならストリーム2本と通常のAPI呼び出しを賄えます。使用量はusageエンドポイントで無料で確認できます。

ストリームのパラメーターはAPIドキュメントに、ストリームを利用できるプランは料金ページに掲載しています。

ご自身のデータで試してみましょう

無料アカウントを作成すれば、2分以内に最初のリクエストを送信できます。月100リクエスト、クレジットカード不要。

関連記事