跳到內容
主控台

SSE 連線

即時資料以 Server-Sent Events(SSE)推送:用戶端發一個 GET,伺服器保持連線並持續送出事件。一條連線可以同時訂閱多個資料流(topic)。每則資料都經過 gzip 壓縮再 base64 編碼,用戶端要逐則解碼。

topic 與單一 topic 路徑 內容 權限 授權
trem.rts.v1
/api/v1/trem/sse/rts
TREM 測站即時快照(JSON),每秒 2 則 trem.rts 不可商用
trem.intensity.v1
/api/v1/trem/sse/intensity
TREM 震度速報(XML) trem.intensity 不可商用
eq.report.v1
/api/v1/trem/sse/report
中央氣象署地震報告(JSON) eq.report 可商用

TREM 資料是非官方觀測,一切以中央氣象署發布為準。依《氣象法》,不得用來預測地震或發布地震警報。 閱讀完整警語

topic 名稱最後的 .v1 是資料格式的版本;路徑裡的 /v1 是 SSE 連線協定(問候、事件名稱、壓縮、心跳)的版本。

  • 依訂閱的 topic 而定:每個 topic 的資料適用它自己的類別與標示文字。

標示位置、範例與商業使用申請見資料授權與來源標示。

可用的 topic 裡的三個 topic 都是公開 topic:不帶 token 也能連線,也不檢查 token 的權限或訂閱。

帶 token 的方式(擇一):

  • header Authorization: Bearer <token>(建議)。前綴必須剛好是 Bearer (大小寫敏感),其他格式會被忽略。
  • query token=<token>。header 沒有取得 token 時才會看 query。

SSE 不讀 X-Api-Token header。Token 的取得方式見 API Token。

帶 token 與不帶 token 的差別:

情況 結果
不帶 token 正常推送,不計入任何帳號。多工入口的 info 會帶 "anonymous": true
帶有效 token 正常推送,並計入該帳號的用量:每條連線上每個 topic 每存活 1 秒記 1 次,記在該 topic 的權限名稱(資源)下。超過配額不會切斷公開 topic
帶無效、過期或已撤銷的 token 整個請求回 401 invalid token,公開 topic 也不推送。不需要計量時請不要帶 token
帶 token,但驗證服務暫時無法使用 公開 topic 照常推送,但當成未驗證("anonymous": true),不計量

用量與配額的計算見 用量與配額。

需要權限的 topic 會依序檢查 token 權限、帳號訂閱、配額;不通過的原因字串見斷線與錯誤原因。在多工入口,只要有一個 topic 通過就會回 200,沒通過的列在 info 的 denied。

Base URL 用負載平衡入口 https://api.lb.exptech.dev(也可以指定 https://api.lb-tpe1.exptech.dev 或 https://api.lb-khh1.exptech.dev)。有兩種入口:

  • 多工入口 /api/v1/trem/sse?topics=...:一條連線收多個 topic。每則資料都帶 event: <topic 名稱>,用事件名稱分流。建議新程式使用。
  • 單一 topic 路徑(見上表):只收一個 topic。資料事件沒有 event: 行,瀏覽器 EventSource 用 onmessage 就收得到。
GEThttps://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1
Query 參數
  • topicsstring必填

    要訂閱的 topic,逗號分隔;也可以重複帶多次(?topics=a&topics=b)。名稱大小寫敏感、必須完全相同,前後空白會去掉、重複的會合併。只有多工入口需要。

  • modestring選填預設 sleep

    live(不分大小寫)會收到每一則 trem.rts.v1 快照;省略或其他值都是 sleep 模式。見「模式」。

  • tokenstring選填

    API Token。只給不能設定 header 的用戶端(例如瀏覽器 EventSource)使用;其他情況請用 Authorization header。

請求範例
curl -N "https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1"
回應

連線成功,開始推送 text/event-stream

retry: 3000
event: info
data: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1
data: H4sIAAAAAAAC/02OywrDIBRE...
: ping 1790619700

topics 未帶或名稱錯誤

unknown topic: trem.rts

token 無效、過期或已撤銷

invalid token
  • 只接受 GET,其他方法回 405(本文 method not allowed)。
  • 成功時的回應 header:Content-Type: text/event-stream、Cache-Control: no-cache、X-Accel-Buffering: no。
  • 跨來源(CORS):所有回應都帶 Access-Control-Allow-Origin: *,預檢請求允許 Authorization header,所以瀏覽器可以用 fetch() 帶 header 連線。
  • 一條多工連線上有多個 topic 同時有資料時,依 trem.intensity.v1、eq.report.v1、trem.rts.v1 的順序先送。

mode 只影響 trem.rts.v1,對整條連線生效:

mode trem.rts.v1 trem.intensity.v1、eq.report.v1
sleep(預設) 只送「至少有一個測站帶 alert」的快照。平常沒有測站觸發時,這個 topic 完全沒有資料(只有心跳) 每則都送
live 每則快照都送(每秒 2 則) 每則都送

sleep 模式送出的快照內容與 live 模式相同,仍然包含所有測站,只是沒有測站觸發的快照不送。

要切換模式必須重新連線。建議先用新模式開第二條連線,收到 event: info 後再關掉舊連線,中間就不會漏資料。

連線成功後,伺服器依序送出:

retry: 3000
event: info
data: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1
data: H4sIAAAAAAAC/02OywrDIBRE/+Wub8XrIzHumob+RMhCgpRAia3aVci/VyF9zGrgHIbZIIVXnD1YmEP0p7yuBAg5gaW24w11jeElCCm7vIS1gA2IWiP0pdYFLGcFP26utoZ2LHi4Dv0Pqy82LYK7+5jLfBXPmvdkDlEyeYiKcf0nFtM/wY6jkKxTSIKYFigFfi4qPU37G7yO1gbLAAAA
: ping 1790619700
事件 data 內容 壓縮 說明
(retry: 3000) 無 否 建議的重新連線間隔 3000 毫秒,EventSource 會自動採用
info JSON 否 連線成功後的第一則事件,欄位見下表
topic 名稱(多工入口)/沒有 event: 行(單一 topic 路徑) base64 字串 是 資料,見解碼資料
unsubscribed JSON {"topic":"...","reason":"..."} 否 多工連線上某個 topic 被停止,其他 topic 繼續
close 純文字原因 否 伺服器即將關閉這條連線
: ping <Unix 秒> (註解行,沒有 data) 否 心跳,見心跳

info 的欄位:

欄位類型說明
locationstring提供這條連線的伺服器節點名稱,例如 lb-tpe1。
topicsstring[]實際訂閱到的 topic,依推送優先序排列(不是請求的順序)。只有多工入口有。
deniedobject沒有訂閱到的 topic → 原因。全部都訂到時省略。只有多工入口有。
anonymousboolean這條連線沒有驗證到帳號(不計量)時為 true,否則省略。只有多工入口有。

事件沒有 id: 欄位,伺服器也不讀 Last-Event-ID。重新連線後不會從中斷處接續;各 topic 連線時會不會補送,見重新連線。

伺服器每 60 秒送一行註解:

: ping 1790619700
  • 數字是伺服器時間(Unix 秒,經 NTP 校正)。
  • 一條多工連線不論訂幾個 topic,每 60 秒只送一次。
  • sleep 模式下沒有資料時,心跳是唯一的流量,可以用來判斷連線是否還活著:90 秒內沒有收到任何位元組就當作斷線並重連。
  • EventSource 會忽略註解行,程式看不到心跳;自己解析串流時,不要把它當成資料。

每則資料事件的 data 是:原始內容 → gzip 壓縮(每則都是一個完整的 gzip 資料)→ 標準 base64(含 = 補位)。回應沒有 Content-Encoding: gzip,HTTP 層不會自動解壓,請逐則自己解碼。info、unsubscribed、close 不壓縮。

解碼後的格式:trem.rts.v1 與 eq.report.v1 是 JSON,trem.intensity.v1 是 XML,都是 UTF-8。

瀏覽器(使用 DecompressionStream):

async function decodeData(b64) {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}

Node.js:

import { gunzipSync } from "node:zlib";
function decodeData(b64) {
return gunzipSync(Buffer.from(b64, "base64")).toString("utf8");
}

Python:

import base64
import gzip
def decode_data(b64: str) -> str:
return gzip.decompress(base64.b64decode(b64)).decode("utf-8")

上面串流格式範例裡 trem.rts.v1 那一則,解碼後是:

{"source":"core-tnn1","ts":1790619680000,"stations":{"117825C":{"i":0.0,"pga":0.61},"11DFDBC":{"i":0.4,"pga":0.87,"alert":1},"1A50B18":{"i":3.3,"pga":4.05,"alert":1}},"eq":[[23.94,121.52,32,1790619645]]}
情況 建議做法
網路中斷、伺服器關閉連線(沒有 event: close) 等 3 秒後重連;連續失敗時加倍等待(3、6、12…最多 60 秒)並加上隨機延遲。收到 event: info 後把等待時間重設回 3 秒
90 秒內沒有收到任何位元組 主動中斷並重連
HTTP 400 請求本身有錯(topic 名稱、缺少 topics),修正前不要重試
HTTP 401 invalid token token 無效或已撤銷,不要用同一個 token 重試
HTTP 429 等至少 60 秒再試
HTTP 503 暫時性錯誤,依上面的退避規則重試
event: close,data: token revoked 停止重連,換新的 token

EventSource 遇到網路中斷會依 retry: 3000 自動重連;但伺服器回非 200 狀態時它會直接停止(readyState 變成 CLOSED),要自己重新建立。收到 event: close 後請呼叫 close(),否則 EventSource 會自動重連。

重新連線後,斷線期間的資料:

  • trem.rts.v1:不補送。每則都是完整快照,繼續處理下一則即可。
  • trem.intensity.v1:連線時會先補送最近 240 秒內每場震度速報的最後一報。可能和斷線前收過的重複,請用 EventID + Serial 去重。
  • eq.report.v1:不補送。重連後請呼叫 GET /api/v2/eq/report 補齊。

伺服器不保證每則都送達:連線消化不及時,伺服器會丟掉這條連線佇列中最舊的資料(trem.rts.v1 只保留最新一則);一次寫出超過 30 秒還送不出去,連線會直接中斷,不會有 event: close。

建立連線失敗時,伺服器回一般 HTTP 狀態碼,本文是純文字(結尾有換行):

狀態 本文(原文) 何時發生
400 topics required, e.g. ?topics=... 多工入口沒有帶任何 topic
400 unknown topic: <名稱> topic 名稱錯誤(大小寫敏感)
401 invalid token 帶了無效、過期或已撤銷的 token(不論訂哪些 topic)
401 unauthorized 需要權限的 topic 沒有帶 token
403 forbidden: token scope missing token 沒有該 topic 的權限
403 forbidden: not subscribed 帳號沒有訂閱該資源
429 quota exceeded 帳號在該資源已超過配額;之後約 60 秒內重連仍是 429
503 auth service unavailable 驗證服務暫時無法使用,而且請求了需要權限的 topic
405 method not allowed 不是 GET

在多工入口,所有請求的 topic 都被拒絕時,401、403、429、503 的本文後面會加上 : <topic 名稱>,例如 forbidden: not subscribed: <topic 名稱>。只要有 topic 通過,就回 200,被拒絕的 topic 放在 info.denied,原因是 unauthorized、forbidden: token scope missing、forbidden: not subscribed、quota exceeded 其中之一(不加 topic 名稱)。

連線建立後,伺服器可能送出:

事件 data(原文) 何時發生
unsubscribed {"topic":"<topic 名稱>","reason":"quota exceeded"} 需要權限的 topic 超過配額,只停止這個 topic,其他 topic 繼續
close quota exceeded 最後一個 topic 因超過配額被停止,連線關閉
close token revoked token 被撤銷。約 5 秒後關閉該帳號的所有 SSE 連線(包含公開 topic);用同一個 token 重連會得到 401 invalid token
(沒有事件) — 網路中斷、寫出逾時(30 秒)、伺服器重新啟動

公開 topic 超過配額不會收到 unsubscribed 或 close。

兩個可以直接執行的完整用戶端,示範解碼、心跳逾時、退避重連與去重。

Node.js 18 以上(內建 fetch),不需要安裝套件。同時訂閱三個 topic,處理心跳逾時、退避重連與去重;連上後先補抓最近的地震報告。

// sse-client.mjs — 執行:node sse-client.mjs
// 環境變數:MODE=live 收全部 RTS 快照;EXPTECH_TOKEN=<token> 選填,帶了會計入帳號用量
import { gunzipSync } from "node:zlib";
const BASE = process.env.BASE ?? "https://api.lb.exptech.dev";
const REPORT_API = process.env.REPORT_API ?? "https://api.core.exptech.dev";
const TOPICS = ["trem.intensity.v1", "eq.report.v1", "trem.rts.v1"];
const MODE = process.env.MODE === "live" ? "live" : "sleep";
const TOKEN = process.env.EXPTECH_TOKEN ?? "";
const STALE_MS = 90_000; // 心跳 60 秒一次,90 秒沒有任何資料就重連
const MIN_RETRY_MS = 3_000;
const MAX_RETRY_MS = 60_000;
class FatalError extends Error {} // 重試也沒用的錯誤
let retryMs = MIN_RETRY_MS;
const intensitySerial = new Map(); // 震度速報 EventID -> 已處理的最新 Serial
const seenReports = new Set(); // 已處理的地震報告 ID
function decodeData(b64) {
return gunzipSync(Buffer.from(b64, "base64")).toString("utf8");
}
function onRts(rts) {
const ids = Object.keys(rts.stations);
const alerted = ids.filter((id) => "alert" in rts.stations[id]);
console.log(`[rts] ${new Date(rts.ts).toISOString()} 測站 ${ids.length} 個,觸發 ${alerted.length} 個,地震 ${rts.eq.length} 個`);
}
function onIntensity(xml) {
const eventId = xml.match(/<Head>[\s\S]*?<EventID>(\d+)<\/EventID>/)?.[1];
const serial = Number(xml.match(/<Serial>(\d+)<\/Serial>/)?.[1]);
if (!eventId || serial <= (intensitySerial.get(eventId) ?? 0)) return; // 補送或重複
intensitySerial.set(eventId, serial);
const maxInt = xml.match(/<MaxInt>([^<]*)<\/MaxInt>/)?.[1];
console.log(`[intensity] ${eventId} 第 ${serial} 報,最大震度 ${maxInt}`);
}
function onReport({ reason, data }) {
if (reason === 3 && Date.now() - data.time > 86_400_000) return; // 回填的舊報告
const isNew = !seenReports.has(data.id);
seenReports.add(data.id);
console.log(`[report] ${isNew ? "新報告" : "更新"} ${data.id} M${data.mag} 深度 ${data.depth} km(reason ${reason})`);
}
async function catchUpReports() {
// eq.report.v1 不補送,連上後用 HTTP API 補抓最近的報告
const res = await fetch(`${REPORT_API}/api/v2/eq/report?limit=5&minIntensity=0`);
if (!res.ok) return console.warn(`補抓地震報告失敗:HTTP ${res.status}`);
for (const r of await res.json()) {
if (!seenReports.has(r.id)) console.log(`[report] 補抓 ${r.id} M${r.mag}`);
seenReports.add(r.id);
}
}
function handleBlock(block) {
let event = "message";
const data = [];
for (const line of block.split("\n")) {
if (line.startsWith(":")) continue; // 心跳 `: ping <秒>`
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
}
if (data.length === 0) return; // `retry: 3000` 或心跳
const text = data.join("\n");
switch (event) {
case "info":
retryMs = MIN_RETRY_MS;
console.log("已連線", text);
catchUpReports().catch((err) => console.warn("補抓地震報告失敗:", err.message));
break;
case "unsubscribed":
console.warn("topic 已停止:", text);
break;
case "close":
if (text === "token revoked") throw new FatalError("token 已被撤銷,請更換 token");
throw new Error(`伺服器關閉連線:${text}`);
case "trem.rts.v1":
onRts(JSON.parse(decodeData(text)));
break;
case "trem.intensity.v1":
onIntensity(decodeData(text));
break;
case "eq.report.v1":
onReport(JSON.parse(decodeData(text)));
break;
}
}
async function connect() {
const url = new URL("/api/v1/trem/sse", BASE);
url.searchParams.set("topics", TOPICS.join(","));
if (MODE === "live") url.searchParams.set("mode", "live");
const headers = { Accept: "text/event-stream" };
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
const ac = new AbortController();
let timer = setTimeout(() => ac.abort(), STALE_MS);
try {
const res = await fetch(url, { headers, signal: ac.signal });
if (!res.ok) {
const body = (await res.text()).trim();
if ([400, 401, 403, 405].includes(res.status)) throw new FatalError(`HTTP ${res.status} ${body}`);
throw new Error(`HTTP ${res.status} ${body}`);
}
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
clearTimeout(timer);
timer = setTimeout(() => ac.abort(), STALE_MS);
buf += decoder.decode(chunk, { stream: true }).replace(/\r/g, "");
let end;
while ((end = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, end);
buf = buf.slice(end + 2);
handleBlock(block);
}
}
} finally {
clearTimeout(timer);
ac.abort();
}
}
for (;;) {
try {
await connect();
console.warn("連線已結束");
} catch (err) {
if (err instanceof FatalError) {
console.error(err.message);
process.exit(1);
}
console.warn("連線中斷:", err.name === "AbortError" ? "90 秒沒有收到資料" : err.message);
}
const wait = retryMs + Math.random() * 1000;
retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
console.log(`${Math.round(wait / 1000)} 秒後重連`);
await new Promise((resolve) => setTimeout(resolve, wait));
}

使用 EventSource 訂閱公開 topic(不帶 token)。解碼是非同步的,用一個 Promise 佇列保持處理順序。

<!doctype html>
<meta charset="utf-8" />
<title>ExpTech SSE</title>
<pre id="log"></pre>
<script type="module">
const URL_ = "https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1";
const log = (msg) => (document.getElementById("log").textContent = `${msg}\n` + document.getElementById("log").textContent);
async function decodeData(b64) {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
// 依收到的順序處理(decodeData 是非同步的)
let queue = Promise.resolve();
const inOrder = (fn) => (event) => {
queue = queue.then(() => fn(event)).catch((err) => console.error(err));
};
const intensitySerial = new Map();
let es;
let retryMs = 3000;
function connect() {
es = new EventSource(URL_);
es.addEventListener("info", (e) => {
retryMs = 3000;
log(`已連線 ${e.data}`);
});
es.addEventListener("trem.rts.v1", inOrder(async (e) => {
const rts = JSON.parse(await decodeData(e.data));
const alerted = Object.values(rts.stations).filter((s) => "alert" in s).length;
log(`RTS ${new Date(rts.ts).toLocaleTimeString()} 觸發測站 ${alerted} 個`);
}));
es.addEventListener("trem.intensity.v1", inOrder(async (e) => {
const doc = new DOMParser().parseFromString(await decodeData(e.data), "application/xml");
const eventId = doc.querySelector("Head > EventID").textContent;
const serial = Number(doc.querySelector("Head > Serial").textContent);
if (serial <= (intensitySerial.get(eventId) ?? 0)) return; // 補送或重複
intensitySerial.set(eventId, serial);
log(`震度速報 ${eventId} 第 ${serial} 報 最大震度 ${doc.querySelector("MaxInt").textContent}`);
}));
es.addEventListener("eq.report.v1", inOrder(async (e) => {
const { reason, data } = JSON.parse(await decodeData(e.data));
log(`地震報告 ${data.id} M${data.mag}(reason ${reason})`);
}));
es.addEventListener("close", (e) => {
log(`伺服器關閉連線:${e.data}`);
es.close(); // 不關閉的話 EventSource 會自動重連
if (e.data !== "token revoked") reconnectLater();
});
es.onerror = () => {
// 網路中斷時 readyState 是 CONNECTING,EventSource 會自己重連;
// 伺服器回錯誤狀態時 readyState 是 CLOSED,要自己重新建立。
if (es.readyState === EventSource.CLOSED) reconnectLater();
};
}
function reconnectLater() {
const wait = retryMs + Math.random() * 1000;
retryMs = Math.min(retryMs * 2, 60_000);
setTimeout(connect, wait);
}
connect();
</script>
  • 要收多個 topic 時,用多工入口合併成一條連線。用量計算與分開連線相同,但只需要一次驗證、一條連線。
  • trem.rts.v1 在 live 模式每秒 2 則、每則包含所有測站,是流量最大的 topic;只需要地震期間的資料時用預設的 sleep 模式。
  • 各 topic 的資料格式見 trem.rts.v1、trem.intensity.v1、eq.report.v1。