SSE 連線
即時資料以 Server-Sent Events(SSE)推送:用戶端發一個 GET,伺服器保持連線並持續送出事件。一條連線可以同時訂閱多個資料流(topic)。每則資料都經過 gzip 壓縮再 base64 編碼,用戶端要逐則解碼。
可用的 topic
Section titled “可用的 topic”| 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:不帶 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就收得到。
https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1 topics要訂閱的 topic,逗號分隔;也可以重複帶多次(?topics=a&topics=b)。名稱大小寫敏感、必須完全相同,前後空白會去掉、重複的會合併。只有多工入口需要。
modelive(不分大小寫)會收到每一則 trem.rts.v1 快照;省略或其他值都是 sleep 模式。見「模式」。
tokenAPI 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"curl -N "https://api.lb-tpe1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1"curl -N "https://api.lb-khh1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1"const res = await fetch("https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1");if (!res.ok) throw new Error(`HTTP ${res.status}`);
// text/event-stream:逐段讀取const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();for (;;) { const { value, done } = await reader.read(); if (done) break; console.log(value);}const res = await fetch("https://api.lb-tpe1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1");if (!res.ok) throw new Error(`HTTP ${res.status}`);
// text/event-stream:逐段讀取const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();for (;;) { const { value, done } = await reader.read(); if (done) break; console.log(value);}const res = await fetch("https://api.lb-khh1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1");if (!res.ok) throw new Error(`HTTP ${res.status}`);
// text/event-stream:逐段讀取const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();for (;;) { const { value, done } = await reader.read(); if (done) break; console.log(value);}import requests
with requests.get( "https://api.lb.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1", stream=True, timeout=30,) as res: res.raise_for_status() for line in res.iter_lines(decode_unicode=True): if line: print(line)import requests
with requests.get( "https://api.lb-tpe1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1", stream=True, timeout=30,) as res: res.raise_for_status() for line in res.iter_lines(decode_unicode=True): if line: print(line)import requests
with requests.get( "https://api.lb-khh1.exptech.dev/api/v1/trem/sse?topics=trem.intensity.v1,eq.report.v1,trem.rts.v1", stream=True, timeout=30,) as res: res.raise_for_status() for line in res.iter_lines(decode_unicode=True): if line: print(line)連線成功,開始推送 text/event-stream
retry: 3000
event: infodata: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1data: H4sIAAAAAAAC/02OywrDIBRE...
: ping 1790619700topics 未帶或名稱錯誤
unknown topic: trem.rtstoken 無效、過期或已撤銷
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: *,預檢請求允許Authorizationheader,所以瀏覽器可以用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: infodata: {"location":"lb-tpe1","topics":["trem.intensity.v1","eq.report.v1","trem.rts.v1"],"anonymous":true}
event: trem.rts.v1data: 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 的欄位:
| 欄位 | 類型 | 說明 |
|---|---|---|
location | string | 提供這條連線的伺服器節點名稱,例如 lb-tpe1。 |
topics | string[] | 實際訂閱到的 topic,依推送優先序排列(不是請求的順序)。只有多工入口有。 |
denied | object | 沒有訂閱到的 topic → 原因。全部都訂到時省略。只有多工入口有。 |
anonymous | boolean | 這條連線沒有驗證到帳號(不計量)時為 true,否則省略。只有多工入口有。 |
事件沒有 id: 欄位,伺服器也不讀 Last-Event-ID。重新連線後不會從中斷處接續;各 topic 連線時會不會補送,見重新連線。
伺服器每 60 秒送一行註解:
: ping 1790619700- 數字是伺服器時間(Unix 秒,經 NTP 校正)。
- 一條多工連線不論訂幾個 topic,每 60 秒只送一次。
- sleep 模式下沒有資料時,心跳是唯一的流量,可以用來判斷連線是否還活著:90 秒內沒有收到任何位元組就當作斷線並重連。
EventSource會忽略註解行,程式看不到心跳;自己解析串流時,不要把它當成資料。
解碼資料(gzip + base64)
Section titled “解碼資料(gzip + base64)”每則資料事件的 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 base64import 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。
斷線與錯誤原因
Section titled “斷線與錯誤原因”建立連線失敗時,伺服器回一般 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
Section titled “Node.js”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 -> 已處理的最新 Serialconst 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。
