HTTP 103 Early Hints

前幾天晚上前同事陶百貼了個 Tweet,說到 Chrome 要移除 HTTP/2 Server Push 了:

仔細看一下,發現原來大家用 Server Push 都還是為了提升網頁第一屏的速度,但是 Server Push 一直有一些難解的問題,像是不知道 client 端有沒有 cache,實作和支援比較麻煩,而 Chrome 要移除 Server Push 前,其實先實作了RFC-8279 的 HTTP 103: Early Hints,為的就要讓 Server Push 現在作的事情先有替代方案。

Early Hints 應該算是 Fastly 提出的,RFC 文件作者是Kazuho Oku,實際上應該也有其他 Fastly 的人參與構思和試驗,支援 Early Hints 的環境下,一個 HTTP request 看起來就像是下面這樣:

Client request:

  GET / HTTP/1.1
  Host: example.com


Server response:

  HTTP/1.1 103 Early Hints
  Link: </style.css>; rel=preload; as=style
  Link: </script.js>; rel=preload; as=script

  HTTP/1.1 200 OK
  Date: Fri, 26 May 2017 10:02:11 GMT
  Content-Length: 1234
  Content-Type: text/html; charset=utf-8
  Link: </style.css>; rel=preload; as=style
  Link: </script.js>; rel=preload; as=script

  <!doctype html>
  [... rest of the response body is omitted from the example ...]

很特別的,就是在於有兩段 response,第一段就是 103 的 status code,然後內容就是 Link headers 了,接著才是常見的 200 回應,看到這邊,自然的出現第一個問題:現有的瀏覽器能相容嗎?

這個問題在Stack Overflow 也有人問,結果回答在 RFC 文件內其實就有,只不過是放在第三章的安全性那邊,我一開始也因為先跳過這章而沒發現,總之關於這個問題,就是如果是 HTTP/2 的話,就比較沒問題,HTTP/1.1 的話,理論上應該要可以相容(沒功能但是也不出錯),但是無法保證現在有在用的 HTTP/1.1 client 都有正確的處理 1xx response,所以比較建議是 HTTP/2 才回 103。

過了兩天後,我更仔細的研究一下,發現其實早在 HTTP/1.1 時,就有把1xx 的處理需求定義好了:

A client MUST be able to parse one or more 1xx responses received prior to a final response, even if the client does not expect one. A user agent MAY ignore unexpected 1xx responses.

就是說早在 HTTP/1.1 時的設計,就允許 1xx 接 200 的回應,而且還應該要支援多個 1xx 回應,而最後的那個 200(其實是 2xx 到 5xx 都可以),則是稱為 final response,至於這處理的方式,在 WHATWG 的 fetch 的 4.7 章則有清楚的寫下流程,在該章節的第九項裡面的第五子項目,寫成程式碼大概長成:

while (true) {
  const response = await networkTransmit();
  const status = response.statusCode;
    
  if (status >= 100 && status <= 199) {
    // handle 1xx response
    continue;
  } else {
    break;
  }
}

// handle final response

所以理論上,Early Hints 的設計在正確支援 HTTP/1.1 但是還沒有支援 Early Hints 的瀏覽器就應該要可以正常的略過,而不會把它當成是 final response。

解決完第一個問題後,接著來仔細的看看剛剛範例的 server response:

HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

HTTP/1.1 200 OK
Date: Fri, 26 May 2017 10:02:11 GMT
Content-Length: 1234
Content-Type: text/html; charset=utf-8
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script

<!doctype html>
[... rest of the response body is omitted from the example ...]

不知道會不會有人疑惑,為什麼不直接用 200 response 裡面回應的 Link header 就好了?其實我一開始也是這樣想,不過這完全是因為這個問題落入身為前端工程師的我的盲點之中,因為現在前端開發主流是 SPA,通常 HTTP server 回的就是一個靜態的 HTML 檔案,所以回應速度超快。不過,如果回應的 HTML 文件,是由程式語言動態生成的,或許還需要查詢一下資料庫之類的,那這個回應時間就會變慢了,而 HTTP 103 Early Hints 就是在這種狀態下用的,在你的 server 端程式開始處理 request 之前,就先丟 103 的 status code 和 Early Hints 的內容回給瀏覽器,然後才接著處理資料和生成 HTML 文件,這種情境下,Early Hints 就顯得比較有差異了。Nitropack 的文章就解釋的很清楚,還有附上詳細的說明圖。

相較於 Server Push,其實 Early Hints 的設計簡單很多,所有的傳輸還是從 client 端看有沒有 cache ,決定要不要發 request,而這種操作已經非常成熟(相較於 server push),相信很多地方可以直接使用現有的程式碼來實作,最大的隱憂,就只是不相容 HTTP/1.0,然後會擔心有 HTTP/1.1 的 client 端沒正確實作吧,畢竟 1xx 的處理機制雖然早早就設計好,但是實際上 1xx 有被廣泛使用也是這幾年的事。

目前 Chrome 是從 103開始支援 Early Hints的,並且預計在 106正式移除 Server Push,至於其他瀏覽器則是都還沒有支援, Firefox 是有計畫要支援,進度有點緩慢就是。

最後,Fastly 其實有提供一個測試用的網站:https://early-hints.fastlylabs.com/,不過這個網站不是用來測試你的瀏覽器支不支援 Early Hints 的,而是用來測試先 103 然後接 200 的 response 會不會有非預期的問題(也就是相容性的測試),如果想要直接看看來回的內容,也可以直接用 curl:

curl -v https://early-hints.fastlylabs.com

Shopify App

之前開發 Shopify App 時,為了搞定他的安裝搞了蠻久,所以決定來紀錄一下踩到的坑,這篇文章適合已經開始在開發 Shopify App 的人閱讀,有些 Shopify App 的基本知識就不會提到,以下內文幾個名詞先定義清楚一下:

  • App 指的是我們開發的 Shopify 第三方 app
  • Merchant 指的是在 Shopify 上開店的商家
  • 安裝 app 指的是 merchant 在他們的 Shopify 商店上安裝我們開發的第三方 app

首先就是,我踩的很多坑有一部分原因是因為我用 NodeJS 作為 server 端的語言,選的是 Express,但是官方的 Express 架構的 app 範例已經停止維護了,取而代之的,是 Koa 版本的@shopify/koa-shopify-auth,只有負責驗證相關的 middleware,不過其實我也就剛好是需要 auth 相關的部分,只是差在不是 Express 版,我也還可以研究看看要怎樣自己實作了。

大概看一下,發現其實還有另外一個@shopify/shopify-api是底層負責處理跟 Shopify 相關的邏輯,所以理論上我也可以使用它來搭配 Express,不過這裡首先就有一個坑了,初始化的範例是長這樣:

Shopify.Context.initialize({
  API_KEY: process.env.SHOPIFY_API_KEY,
  API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
  SCOPES: process.env.SHOPIFY_APP_SCOPES,
  HOST_NAME: process.env.SHOPIFY_APP_URL.replace(/^https:\/\//, ''),
  API_VERSION: ApiVersion.October20,
  IS_EMBEDDED_APP: true,
  // More information at https://github.com/Shopify/shopify-node-api/blob/main/docs/issues.md#notes-on-session-handling
  SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
});

可以看到,最後有一個SESSION_STORAGE,這是個處理 merchant 在安裝 app 時,我們的 app 拿到的 access token 的儲存方式的 adapter,不過官方的範例是用 Memory Storage,這個 adpater 是只有存在記憶體內,其實只適用於開發用,只要你的 server 一重開,所有的 merchant 就都要重新安裝你的 app,不然你的 app 會沒有 access token 跟 Shopify 溝通,實際上你應該要參考Custom Session Storage這份文件,挑選適用的 adapter,我則是參考範例寫了一個 GCP FireStore 的版本,當然另外沒特別提到的就是,因為是儲存 access token,最好要考慮一下 DB 的加密。

第二個坑,就是要怎樣做 Shopify 的 authentication 以及 identification,先來說如何驗證 request 是可信的,在 Shopify API 的設計,就是要靠 query string parameter 裡面的 hmac,他是根據你的 App 的 secret 來計算出來的,然後,這裡的坑就是官方套件@shopify/shopify-api內有個validateHmac可以用,但是它的計算其實是不正確的,它是用白名單只有取部分的 query string parameter 來計算,結果和 Shopify 給的就會有出入,所以我是參考 GitHub issue 討論串內 Muhammad Kamal 給的範例來使用。

第三個坑,則是安裝 App 用的 route 了,Shopify 的設計有點特別,所有的初始 request (不論是第一次安裝、還是從 Shopify 後台進入 App 的設定畫面),都長的很接近,所以你就要根據各種狀況來決定該做什麼事情,以下是所有可能的狀況:

  • 第一次來安裝
  • 安裝後進到設定畫面
  • 曾經安裝過,但是需要重新授權,可能的原因:
    • App 需要的權限有變動
    • App 端的 access token 失效了
  • Shopify 認為已經安裝了,但是 app 端沒資料

扣除需要的權限有變動之外,其實就是排列組合,Shopify 端認為有沒有安裝過,和 App 端認為有沒有安裝過,二乘二共四種可能性,不過實際上只有三種處理方式:初次安裝、重新授權、安裝沒問題的快樂路線(happy path)整理成程式流程大概是:

  1. 驗證 hmac,沒過可以直接回 400
  2. 判斷 shop 是否有在資料庫中
  3. 2 有的話驗證資料庫中的 access token
  4. 3 驗證通過的話,狀態就是 happy path,Shopify 認為 app 有裝,app 端檢查也沒問題,我把這狀態命名為valid
  5. 3 驗證沒通過的話,判斷有沒有session這個 query string 參數
  6. 5 有的話,狀態就是 app 端的 access token 不能用了,需要走重新授權的流程,我把這狀態命名為invalid
  7. 5 沒有的話,就是第一次安裝的流程,我把這狀態命名為not_found
  8. 最後就是 2 沒有的話也是走初次安裝的授權流程,同樣也可以叫not_found

然後 app 需要的權限變動的話,理論上是每次進來,驗證 access token 的時候,可以去打 API 問目前token 的 access scope,不過這部份我沒實做,因為目前我還沒有相關需求。

網路上可能可以找到X-Shopify-API-Request-Failure-Reauthorize這個 header,不過這個其實不是 Shopify API 的回應,而是 Shopify 的app-template裡面設計的機制,它們的 app template 裡面,server 端在轉發 Ajax API request 時,如果收到 Shopify 端的錯誤後,就加上這個 header 回給 app 前端,app 前端收到這個 header 後就可以透過 Shopify app-bridge 進入重新授權的流程。

講到這邊,或許有人會好奇,為什麼需要把安裝 app 和重新授權兩個流程的處理方式分開?其實這可以算是第四個坑,也是和使用者體驗有關係,狀況就是,Shopify 認為是初次安裝時,是直接進入 OAuth 的流程,所以是瀏覽器的最上層視窗直接轉址到 auth 頁面,但是如果是需要重新授權的情形,則是 Shopify 端認為已經安裝好,但是 app 這邊認為需要重新跑一次 OAuth,而這時候,連到 app server 的瀏覽器視窗是在 Shopify 商店後台的 iframe 內,在 iframe 內也無法正確的完成 OAuth 授權,所以需要用 Shopify 現在一套叫 app-bridge 的工具幫忙,讓 OAuth 流程從最上層視窗開始,所以需要回一個 HTML 頁面,引入 app-bridge 的 script,然後執行以下的的 JS:

const AppBridge = window['app-bridge'];
const createApp = AppBridge.default;
const Redirect = AppBridge.actions.Redirect;
const app = createApp({
	apiKey: '{{API_KEY}}',
	host: '{{HOST}}',
});
const redirect = Redirect.create(app);

redirect.dispatch(
	Redirect.Action.REMOTE,
	'/url/to/your/auth?shop={{SHOP}}'
);

當然記得要把該替換的東西替換上去,然後就可以看到正確的從最上層視窗開始進入 OAuth 授權的流程了。

最後一個坑,其實就是 merchant 反安裝 app 後,Shopify 和 app 端的狀態就會不一致的問題,Shopify 端認為沒安裝,但是 app 端認為有安裝,雖然我上面設計的程式流程已經可以處理這種狀況(驗證 access token 會失敗,然後沒有session參數,所以會進入初次安裝),但是這種情形還是應該要能避免就避免,而解法就是要支援 webhook,要作的事情就是:

  1. 安裝完成的 callback 去訂閱APP_UNINSTALLED這個 webhook event
  2. 然後在收到這個事件後,把資料庫中的對應資料刪除

這邊我是用@shopify/shopify-api提供的工具像是Shopify.Webhooks.Registry.registerShopify.Utils.deleteOfflineSession,真的想要自己作也不是辦不到,不過我記得 Shopify 的 webhook 處理起來有點麻煩。

這些細節就是官方文件沒有好好寫清楚,雖然官方文件內容已經很多,有努力整理了,但是實際上要自己接就還是遇到了不少問題,所以特別寫一篇文章紀錄,雖然不知道會不會有其他中文圈的人需要自己來做 Shopify app 就是了,可以直接用他們的 app template 還是比較簡單啦。


COSCUP 2022

(照片是今年的新玩意,紀錄組的形象照)

今年 COSCUP 恢復實體活動,而我和我的稿件間的緣份也終於來了(有來聽的就知道我等緣份到等了兩年有),所以今年是小孩出生後難得有稿可以投,也幸運的投上了,於是就參加了第一天的活動,今年活動的人潮我目測和往年差不多,後來晚上官方也很快速的公布了明調的數字,也有一千三四百筆數據。

我的分享時間是下午的兩點,我大概早上十點半才到會場,先去摩斯買杯紅茶,然後在門口報到區遇到昨天晚上在前夜 party 淋雨全濕的菜骨,接著我先去攤位區亂晃,在 MozTW 的攤位跟 Irvin 閒聊一下,還有遇到 Ett 和 RJ,之後又在形象照(今年紀錄組的新玩意)區遇到丞相還有幾位紀錄組的新朋友(Ada 和另外一位沒記起名字),拍了些 COSCUP 形象照,還遇到 Bob 和哈維、日落,這邊要先岔題澄清一下,就是我常被誤認我有當過 COSCUP 紀錄組長,不過其實沒有,某一年 Bob 有詢問過我意願,那一年我想要拼研究所畢業所以思考過後還是放棄,結果同一年我後來還是接了其他場活動的紀錄組長,因為我後來還是放棄該年度畢業了...

之後去買了一批 COSCUP 紀念品,然後攤位區拿了一點小點心要回家給小孩,接著亂晃一下發現時間已經接近十二點了,而且肚子有點餓就趕快去覓食,不意外的摩斯爆炸,所以轉戰到一餐點了個可以快速吃完的東西,快速的解決後回到會場,然後和一位台大社團的學弟閒聊一陣子,中間還有遇到大助、Trista、Singing 等人,差不多到一點的時候我決定先去教室內休息等待,途中剛好經過 OCF 攤位有跟 Rock、Max 打招呼,然後也看了一下開源星手村桌遊,OCF 攤位還有位朋友(應該是 OCF 實習生)說之前有到我的動森島上參觀過,之後我就到教室坐著休息一下,接著就是 Max 的分享場次了,他分享的題目是開源軟體與社群 - 參與國際社群經驗談,是我的前一個場次。

其實即使到現在,我還是有點難恢復純會眾的角色,到了會場就是會想到處走來走去,有點難靜下心來聽別人分享,連排想聽的主題都有點難,實在是當太多次志工的後遺症,今年聽Max的分享是我近年難得有真的能夠靜下心來認真作聽眾的經驗,必須說這真的是緣份,要不是剛好排在我的前一個場次,我也不會能夠靜下來聽,而且不聽還好,一聽下去發現內容有很多地方和我的講題部分想說的地方有呼應到,甚至是我想表達但是我沒有很明確寫出來的。

總之接著就換我分享了,今年有認真練習了幾次,有發現時間有點緊,所以實際分享時有一些細節漏了,這邊剛好列一下:

  • 如何開始貢獻那邊,原本有想提到opensource.guide,第二天小飛機的分享:如何跟隨開源技術保持你的職涯發展似乎也有提到,期待之後的錄影
  • Mike LinksvayerBen Balter的介紹,兩位都是 GitHub 員工,不過看職位似乎也不是專門處理 open source license 相關的,倒是會後 Bob 在 FB 有提到他也認識(?) Mike
  • 如何跟 Mike Linksvayer 和 Ben Balter 溝通關於使用 Vim License 的那超過一千個的 public repositories
  • 開 issue 跟 PR 時應該怎樣說明
  • 更進一步介紹怎樣比對 Licensee 的比對原理
  • 選擇你最舒服的方式參與,這句是從 Max 的投影片借來的,我自己的分享是說了兩三次佛系貢獻、等待緣份,其實和 Max 說的「選擇你最舒服的方式參與」是很接近的,我想表達的比較是不給自己和對方壓力
  • 本來還想畫張 timeline 的,還有投稿之後準備內容時於到的外部誘惑等等(那個什麼法環的)
  • 然後就有點離題的商業和開源難分難解的關係

其實我在分享講話的時候,大腦沒什麼在思考的感覺,很像是直覺反射那樣,所以不太能臨時性的控制和調整,這些缺漏就只能等等看之後寫成文字紀錄的時候能不能補上了,附上分享的投影片

除了缺漏之外,其實這次分享還有點可惜是投影解析度不太夠,因為我其中一張投影片的動畫的關係(SPDX 那邊的討論串),我選擇用自己的電腦,不過為了配合大會的錄影,現場的投影解析度就有點低,對現場的聽眾是不太好意思,希望錄影的部分能夠正常可以觀看,其實事後回想,或許我還可以選擇輸出的解析度測試看看,只是不知道那個擷取裝置有沒有支援,當時有點慌忙都忽略了。

分享結束之後我去買了杯氮氣咖啡就快速收工回家了,後來在柏強的 FB 提到了「看到問題的能力」,我就想到我確實之前就有意識到這一點,不過我意識到的是,相較於平常沒主動分享(寫文章或是演講)的人,我確實很容易知道,哪些我的經驗(工作上的、生活上的、興趣上的)可以匯集成一個主題,可能可以寫成文章,也可能可以投稿分享,不過這個能力本身似乎不是這麼容易能分享給新手的。


貓聽得懂人話嗎

菲貓

不知道別人家的貓是怎樣,不過我是蠻相信我家的貓是聽得懂的,我們家的貓咪年紀大了,開始有腎臟問題,行動力也差很多,有一陣子看他身體不舒服,然後還要移動去尿尿埋沙好像很辛苦,於是有一次我就跟他說不要埋了我幫他處理,結果,從此之後他就再也不埋尿尿便便了,即使後來身體比較好也一樣...

我們家貓咪的名字叫菲菲,其實他是一隻蠻特別的貓咪,一來他是很少見的母的橘貓,根據我隨便搜尋了一下,好像要有兩組 DNA 的基因都要是橘色基因,然後機率都是三分之一的樣子;二來他的叫聲很特別,別的貓咪是喵喵叫,他都是凹凹叫,而且很多話;另外就是耳朵的尖端還有些毛也很少在橘貓上見到。這幾個特點之外,其他的地方就和一般常見的橘貓很像了,愛吃、親人、喜歡摸摸,根據我的觀察,家裡的成員當中,他最喜歡我摸他了,常常摸頭頭下巴摸一摸他就乾脆整顆頭都不出力支撐,直接倒在我手上了,啊,他還有一個特色是很會騙人,就我所知有好幾個藍星人跟他接觸之後覺得貓咪很可愛也開始養貓了。

回到聽得懂人話嗎這個問題上,另外一個例子是他前幾兩週身體狀況惡化很多,後腳無力,幾乎走不太動了,尿尿便便常常會來不及跑去貓沙盆,雖然有鋪尿布但是他不一定躺在上面,我就跟他說有事情要叫我們,要便便尿尿都跟我們說,結果就真的開始偶爾會突然開始叫幾聲,然後我們問他什麼事情,他就開始尿尿了(不過還是蠻堅持想要到貓沙上面)。

其實菲菲這次身體狀況惡化,醫生是已經無法處理了,我們就是在家裡讓他盡量過的舒適,晚上事情忙完就會去客廳陪他摸摸他,順便玩對馬戰鬼導演版,所幸他還算可以安靜躺著,因為不知道他到底這樣會不會需要其他協助,還跑去問醫生,不過根據醫生說法,痛苦的話貓咪還是會有反應,像是焦慮、一直換姿勢或是嘔吐等等,總之就是會更慘烈就是,還好菲菲這樣還蠻平靜。菲菲最後是在上週六晚上離開的,他挑了一個剛好沒人在旁邊的時間離開,就是我晚上去倒垃圾的時候,我離開家裡之前還跟他說我要去倒垃圾了,一下子就會回來陪他了,等到我回來時他狀況已經驟變了,呼吸變成很慢,大概好幾秒鐘才一次,然後很吃力了,眼睛也都完全沒反應了,之後只有再呼吸幾次就停了,告別就這樣突然的來到,本來最近看房子還在想不知道到新房子的話要讓他佔據那邊呢,悲傷之餘跟老婆先查過的廠商聯絡,心裡其實也有點逃避現實的想著會不會菲菲其實還活著呢,但是也不敢再多看他幾眼,維持在一個自以為的薛丁格的貓的狀態。

隔天週日天氣很好,週六其實下了很大的雨,所以週日天空特別的蔚藍,空氣很乾淨能見度很高,比這兩天颱風前的天空還要漂亮,在開車往火化園區的路上,看著這一大片蔚藍的天空,想到岳母前幾天有說貓咪在挑日子,就覺得他真的是很會挑日子和時間,挑到天氣這麼這麼好的一天,不過一路上還是會逃避現實的想著他會不會其實還活著,要是還活著就把他送進去火化太可怕了吧,不過這種不切實際的幻想終於還是在禮儀師幫菲菲清潔身體時被打破了,看著完全沒反應不會動的貓咪身體,我心理想著:「啊,真的死了」,等火化出來時,看著骨頭想著:「啊,就這樣變成這樣了」。

跟小孩說貓咪去當小天使了,他雖然不常摸菲菲,但是還是會說不想要看不到貓咪,會想他,看到放在桌上的貓咪罐頭還會怕他去當小天使肚子餓怎麼辦(只好趕快裝到箱子),雖然他沒有反應很大不過似乎這陣子也是有比較敏感。至於我,一開始面對空曠的客廳時感到很不習慣,甚至想逃避那個空間,過了快一週是已經沒那麼害怕客廳了,不過大概是很難繼續玩對馬戰鬼了吧,本來甚至很怕會無法看其他貓咪影片照片的,結果似乎也還好,不過以前要出門時,我都會跟佔據在客廳的菲菲報備一下,這個習慣的慣性即使到現在都還在,或許遲早我還是會習慣菲菲不在了,出門再也不會有要跟他報備的衝動吧,也或許更讓人難受的是知道自己遲早會漸漸忘懷他吧。

PS. 本來想放小孩跟貓咪的合照,不過實在沒很多張,最後挑的是我幫他拍的第一張照片。


CSP for Lambda@Edge

CSP

之前工作上主要是用 AWS,AWS 放靜態網站有過 CloudFront CDN 時,如果需要調整 header 的話,官方的解決方案是用 Lambda@Edge,寫 AWS Lambda function 的時候,其實我個人有一個偏好,就是能不用第三方 module 就不用,主要原因有兩個,第一個原因是,如果程式碼太大包,會無法在 AWS console 上直接看(或修改)程式碼;第二個原因是發佈流程會比較麻煩,因為還要去安裝 module,然後再全部打包起來上傳。

要調整 header 的一個主要原因就是為了 security headers,大部分的 security header 都還算單純,但是 CSP(Content Security Policy)就複雜很多了,如果沒有用結構化的資料,其實很難維護,但是針對 Lambda function 我又不想要用第三方 module,最後我想到的解決方案,就是設計一個很簡短的工具函式來把結構化的資料轉成 CSP header 的值,這就是我最近趁 COSCUP 2021 會議期間整理好的新的 open source 專案:CSP

這個專案內容就只是一個簡單的 function:

const CSP = (directives) => {
  return directives
    .map((directive) => {
      return `${directive.name} ${directive.value.join(' ')};`;
    })
    .join(' ');
};

不過為了好好設計這個 function 其實我也是花不少功夫,首先就是輸入參數的結構要長怎樣,其實一般比較常見的是用物件 property 直接就作為 directive name 的形式,像是 Google 的CSP Evaluator

{
  "default-src": ["'none'"],
  "script-src": ["'self'"],
  "connect-src": ["blah", "blah"]
}

這種結構比較精簡,但是問題就是無法保證順序,考慮再三之後,決定還是用陣列的形式:

[
  {
    "name": "default-src",
    "value": ["'none'"]
  },
  {
    "name": "script-src"",
    "value": ["'self'"]
  }
]

這樣就可以讓開發人員確保輸出的順序,其實大部分時候我也不會那麼在意順序,不過要是default-src如果不是第一個感覺就很不舒服。確定主要的資料結構後,再來就是屬性名稱要用什麼好的問題了,為了找到正確的名稱,我去翻了CSP spec找到關於 parsing 相關的說明,確定了 spec 定義的結構是這樣的(使用 TypeScript 語法):

type Source = string;

type Directive = {
  name: string;
  value: Source[];
};

type Policy = {
  source: "header" | "meta";
  disposition: "enforce" | "report";
  directiveSet: OrderedSet<Directive>;
};

type Policies = Policy[];

在輸入資料的陣列中,每個元素都是DirectiveDirective的兩個屬性分別是namevaluevalue則是Source的陣列集合,當然Source還有更嚴謹的定義,不過這邊就簡化成字串就好。確定完輸入資料的結構後,就是要想盡辦法簡化 function 的內容了,但是也不希望太難讀懂,調整了幾次變成現在的樣子,我還提供了精簡的版本:

const CSP = p => p.map(d => `${d.name} ${d.value.join(' ')};`).join(' ');

其實我對於那個mapjoin一直耿耿於懷,很想要用reduce解決,但是要避免頭尾多空白,會需要多判斷式,就算不予理會,程式碼長度其實還是比現在這個版本長,結果還是mapjoin看起來比較漂亮,所以最後的版本就維持這樣了。

然後我還寫了測試和提供了兩個example,分別是 Lambda@Edge 和 Cloudflare Workers 的,不確定還有沒有類似的服務,如果有發現會再加上。最後就是,因為這個 function 設計就是要給人複製貼上的,所以並沒有發布到 npm 上,然後使用 MIT-0 license 所以也不用 attribution,覺得有興趣使用的就請直接複製貼上吧~

PS. 如果有其他需求,可以看看csp-header,例如 Express 使用,我覺得介面設計得很不錯。


➡ 看看其它文章