本文同步發表於個人部落格:開發環境準備
操作依據:SMART JS Client。
火線超人後來接觸的測試伺服器變多。每台的 Base URL、OAuth 能力與 client 類型都不一樣。於是逐步整理出 FhirServerRegistry,把「這個 app 現在連哪裡」集中管好。
我的體會是:開發環境不只是把專案跑起來,還要知道資料從哪裡來。 今天先固定一組公開、可重複的組合,當作後續講解授權的起點。
「sandbox」這個字常常把不同東西混在一起。在這個系列裡,我們會同時用到三個角色:
| 角色 | 我們使用的工具 | 用途 |
|---|---|---|
| 模擬 EHR | SMART Health IT Launcher | 從 EHR 啟動 app,附帶 iss、launch 與模擬病人情境 |
| 開放 FHIR server | https://r4.smarthealthit.org |
先測試 FHIR 查詢與程式連線,不需授權 |
| 我們的 app | 本機的一個資料夾 | 寫 JS、承接啟動參數,後續完成 OAuth 流程 |
這三者不能互相取代。開放 FHIR server 很適合測試查詢。但它沒有登入、scope 與 launch context。這還不是完整的 SMART 環境。
Launcher 才是練習場。後面模擬 EHR Launch 與 Standalone Launch 都靠它。表格裡的 iss 與 launch 是 EHR 啟動時掛在網址上的參數,明天整篇會講。
使用 SMART Health IT Launcher 不用先註冊帳號。也不用向真實 EHR 申請 client,開啟就能練習。sandbox 只能放測試資料,不要放入真實病人資料。
sandbox 裡的病人叫 Jerrell Gerlach、Marlin Kuphal。生日、用藥、檢驗值一應俱全。這些不是真人去識別化來的,是 Synthea 這套開源工具合成出來的。它由非營利機構 MITRE 開發,Apache-2.0 授權。可以直接輸出 FHIR R4、C-CDA 或 CSV。
Synthea 的做法是替每個虛擬病人跑一遍「人生」。依流行病學模型決定他何時得什麼病、走哪條臨床照護流程、開什麼藥、做哪些檢驗。然後把整段病史輸出成 FHIR 資源。所以那些資料在統計上像真的,臨床邏輯也接得起來。
知道這件事有兩個好處。一是你可以放心地在文章、簡報、公開 repo 裡使用這些資料,不涉及任何個資。二是看到某個病人有 136 筆 Observation 卻只有 2 筆 Condition。那不是資料殘缺,是模型跑出來的合理結果。
這個系列只需要三樣東西:現代瀏覽器、文字編輯器、一個靜態伺服器。
沒有 Node.js、沒有 npm、沒有打包工具。這是刻意的選擇,理由有三個。
第一,SMART 的核心是協定不是工具鏈。 這 30 天要講的是 OAuth 2.0 的授權流程與 FHIR 的資源模型。還有 token 的生命週期,這些全部發生在 HTTP 層。中間插一層打包器,只會讓你在「為什麼建置失敗」上花時間。那些時間本來要用來理解 code_challenge(day08 會整篇講)。
第二,現代瀏覽器已經夠用。 ES modules、fetch、crypto.subtle 這些我們會用到的東西,瀏覽器原生就支援。過去需要打包器,是為了讓舊瀏覽器看得懂新語法。
第三,門檻越低越多人跟得上。 要人先裝 Node 再學 npm,光是這一步就會勸退一部分讀者。這個系列的目的正好相反。
早期的 JavaScript 沒有「模組」這回事。你在 HTML 裡放五個 <script>,這五個檔案的變數就全部擠在同一個全域空間裡。載入順序排錯會壞。兩個檔案不小心用了同名變數也會互相蓋掉。想把程式碼分檔又不出事,過去只能靠打包器幫你合併、改名、包進一層函式裡。
ES module 是瀏覽器後來內建的解法。只要把 <script> 標上 type="module",這個檔案就有兩個關鍵變化:
import 和 export:檔案之間直接互相要東西、給東西,不必經過全域client 也不會打架所以待會你會看到 app.js 開頭那行 export,它不是裝飾。後面幾天我們會把授權流程拆成 discovery.js、pkce.js、auth.js 這幾個檔案。它們要用到伺服器位址就直接 import 那一行。

左邊那兩個 client 撞在一起的時候,瀏覽器不會報錯,也不會警告。後載入的那個就這樣安靜地蓋掉前一個。你只會發現程式行為變得很奇怪,卻找不到是哪一行害的。
反過來看 fhir-client.pure.min.js,它走的是傳統的 <script src>,沒有這層隔離。載入後直接在全域掛一個 FHIR 物件。這就是為什麼待會 app.js 裡可以劈頭就用 FHIR,卻不必先 import 它。
支援度不用擔心,2017 到 2018 年間主流瀏覽器就陸續內建了。但模組多了一條規矩:它是照跨來源的規則去抓檔案的,這件事馬上就會咬到我們。
靜態伺服器則不能省。你可能會想「直接用瀏覽器開 HTML 檔不就好了」,但 file:// 開啟的頁面有兩個限制。
第一個限制跟同源政策有關,後面講授權時還會遇到它。
瀏覽器判斷兩個網址是不是「同源」,看三件事:協定、網域、埠號。三個全都一樣才算同源。所以 https://example.com/a 和 https://example.com/b 同源;但 http://example.com 換了協定、https://api.example.com 換了網域、https://example.com:8080 換了埠號,這三個都不同源。不同源的資源要互相載入,得由對方明確表態允許,這套「表態」機制就是 CORS。
問題在於 file:// 開的頁面既沒有網域也沒有埠號,瀏覽器沒辦法比對。它乾脆把每一個 file 頁面都當成獨立的來源,代號 null。於是即使 index.html 和 app.js 就躺在同一個資料夾裡,載入時仍被當成跨來源請求。而 CORS 只支援 http、https 這幾種協定,file:// 不在名單上。Console 會這樣抱怨:
Access to script at 'file:///…/app.js' from origin 'null'
has been blocked by CORS policy
傳統的 <script src> 不受這條限制,所以 fhir-client.pure.min.js 用 file:// 反而載得進來,只有 type="module" 的檔案會被擋。
第二個限制是授權伺服器的 redirect_uri 必須是 http 位址,file:// 不能當轉址目標。後面幾天走授權流程時一定會撞到。
macOS 與多數 Linux 內建 Python,一行就能起:
python3 -m http.server 5173
Windows 或不裝 Python,用 VS Code 的 Live Server 擴充功能。按一下就好,效果一樣。
建一個資料夾,裡面放三個東西:
smart-app/
├── index.html
├── app.js
└── vendor/
└── fhir-client.pure.min.js
vendor/ 裡是 SMART Health IT 官方維護的 JavaScript client。直接下載進專案:
mkdir -p smart-app/vendor && cd smart-app
curl -o vendor/fhir-client.pure.min.js \
https://cdn.jsdelivr.net/npm/fhirclient@2.6.3/build/fhir-client.pure.min.js
抓下來只有 54 KB。它叫 pure 是因為不含給舊瀏覽器的 polyfill。完整版有 214 KB,我們用不到那些相容層。
為什麼要下載而不是從 CDN 引用?三個理由。一是你的 app 在執行時不會對外發請求,少一個會壞的環節;二是版本鎖死,哪天 CDN 上的檔案改了你的專案不會跟著變;三是這個檔案進了版控,任何人 clone 下來就能跑,不需要網路。
在醫療場域這第三點特別實際,很多醫院的開發機是不能連外網的。
index.html 長這樣:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>SMART App</title>
</head>
<body>
<div id="app">正在連線到公開 FHIR server…</div>
<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
兩個 script 標籤就是前面說的那兩種載入方式。
接著是 app.js:
// 連線設定集中在一處,換 sandbox 只改這裡
export const FHIR_BASE_URL = 'https://r4.smarthealthit.org'
const result = document.querySelector('#app')
const client = FHIR.client({ serverUrl: FHIR_BASE_URL })
client
.request('Patient?_count=1')
.then((bundle) => {
const patient = bundle.entry?.[0]?.resource
result.textContent = patient
? `連線成功:Patient/${patient.id}`
: '連線成功,但沒有找到 Patient 資料'
})
.catch((error) => {
result.textContent = `連線失敗:${error.message}`
})
把 Base URL 放在檔案頂端並 export 出來,換 sandbox 時就不用到處找字串。後面幾天新增的模組也能直接 import 它。這和火線超人 FhirServerRegistry 的思路一樣:連線設定要集中。
有一件事現在講、之後每天都適用:這個檔案會被瀏覽器原封不動下載。任何人按 F12 都看得到。所以裡面只能放 Base URL、client ID 這類公開設定。不能放 client secret、密碼或真實 token。這不是「記得別放」的層次,是「放了就等於公開」。
現在做一次完整驗收:
smart-app 目錄執行 python3 -m http.server 5173。http://localhost:5173。fhirclient、公開 FHIR server 與瀏覽器 CORS 都已經對上。
畫面上那串 id 每個人跑出來不會一樣。_count=1 只是請伺服器隨手給第一筆,換個時間跑可能換一個病人。看到任何一組 id 都算成功。
如果卡住了,照這個順序排查:
頁面停在「正在連線」、Console 出現 blocked by CORS policy 而且 origin 顯示 null。這就是前面說的 file:// 問題,你直接用瀏覽器開了 HTML 檔。回到步驟 1 用靜態伺服器啟動。網址列開頭是 file:/// 而不是 http://localhost 就是徵狀。

這一頁裡被擋的只有 app.js,fhir-client.pure.min.js 其實有載進來。你會遇到「一半正常一半壞掉」的狀況,那不是哪裡打錯了。
出現 FHIR is not defined,代表 vendor/fhir-client.pure.min.js 沒載到。檢查檔案是不是真的在那個路徑,以及 curl 有沒有抓成功。如果網址打錯,你會拿到一個內容是 404 頁面的檔案。大小只有幾百位元組而不是 54 KB。
畫面停在「正在連線」不動,開 Network 分頁看那筆 Patient?_count=1 的請求。狀態是紅色的 CORS 錯誤,表示瀏覽器擋下了跨來源請求;狀態是 200 但頁面沒變,問題在你的 .then() 裡。
顯示「連線失敗」,把 FHIR_BASE_URL 改成備援的 https://hapi.fhir.org/baseR4 再重整。備援能連、SMART 伺服器不能連,問題通常在遠端服務;兩者都不能連,再檢查本機網路或防火牆。
完整可跑的版本在 GitHub 上的 day04-sandbox-setup,想先看跑起來的樣子可以直接開線上版。
今天把開發地基鋪好了。一個不需帳號的 SMART Launcher,一個可查詢的公開 FHIR server。加上一個不需要 Node.js 也不需要打包工具的本機專案。裡面是一個 HTML、一個 JS、一個 vendor 進來的 54 KB 函式庫。
也順帶認識了 Synthea。那些測試病人是照流行病學模型合成的,不必擔心個資。
第一幕到這裡結束。明天正式進入 SMART 核心。先把 EHR Launch 與 Standalone Launch 這兩種啟動模式擺在一起。看懂 app 究竟是從哪裡出發的。