wbr 的這些那些

在 responsive design 成為主流之後,有個問題也隨之被突顯出來,就是文字的換行,尤其是標題文字的換行位置,現在的瀏覽器的換行方式簡單來說就是超出區塊範圍的東西都往下放到下一行,所以在某些情況下,就會有第一行很長第二行很短的狀況,視覺上非常不平衡,以下圖為例,網頁的副標題沒有作特殊處理,所以會有可能會變成兩行長度差距很大的樣子:

line breaking

這個問題有蠻多解決方法的,目前我知道的就有:

  1. 微調 responsvie 的樣式來避免出現不平衡的狀態
  2. 在特定地方插入 避免換行
  3. 用 flex layout 來控制換行位置

基本上就是只要你不擇手段,問題還是可以解決的,不過我一直以來都是會偏好用標準的方法來解決問題,所以整理了一下可以所知道可以拿來用的東西:

  1. <wbr>HTML element
  2. white-spaceCSS property
  3. <nobr>HTML element
  4. text-wrapCSS property

首先的想法是<wbr>配上white-space: nowrap;或是<nobr>,不過意外的是大部分瀏覽器都不支援這個組合,也就是說,包在<nobr>內的<wbr>的地方在現在大部分主流瀏覽器內是不會換行的:

<nobr>ChatGPT: Optimizing<wbr>Language Models<wbr>for Dialogue</nobr>

這就激起了我的好奇心了,於是我開始仔細的找資料,看看<nobr><wbr>到底是怎樣運作的。首先,就來看看<wbr>吧,雖然他第一次出現在 W3C 的文件內就是在 HTML5,但是其實它已經出現了 20 年以上,最早是作為Netscape 的 HTML 2 extension的一員:

The WBR element stands for Word BReak. This is for the very rare case when you have a NOBR section and you know exactly where you want it to break. Also, any time you want to give the Netscape Navigator help by telling it where a word is allowed to be broken. The WBR element does not force a line break (BR does that) it simply lets the Netscape Navigator know where a line break is allowed to be inserted if needed.

在 Internet Archive 上找到的備份,最早的定義其實明確的說著<wbr>處應該是要可以優先於<nobr>的,我甚至還在 bugzilla 上找到一個24 年前的 bug report在講這件事,根據這張票最後的關掉前的討論,其實可以用</nobr><nobr>來達成一樣的效果,然後 Firefox 不打算支援<wbr>,看到這邊,我只能說這解法怎麼這麼天才(稱讚的意味)。

查到這邊,我還是很好奇為什麼現在的主流瀏覽器依然<wbr>優先度比<nobr>還低,所以繼續找資料,這次看的就是最新的文件了,首先是<wbr>定義,在 HTML5 中變成:

The[wbr](https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-wbr-element)elementrepresentsa line break opportunity.

從 "word break" 變成 "line break opportunity" 了,<nobr>則和<wbr>一樣一開始是 Netscape extension,不過他倒是沒有進到 HTML5,事實上,雖然<nobr>不是 HTML5 的一員,但是文件中定義的 default style 還是有它

br { display-outside: newline; } /* this also has bidi implications */
nobr { white-space: nowrap; }
wbr { display-outside: break-opportunity; } /* this also has bidi implications */
nobr wbr { white-space: normal; }

其實就是等價於white-space: nowrap,而white-space屬性在 CSS2 時是用來定義空白的處理方式:

This property declares how white space inside the element is handled.

到 CSS Text Level 3 時定義說的更明確,是用來決定遇到 "line break opportunity" 時的處理方式和是不是要合併 space 字元的屬性:

其實在Level 4更是可以分開設定兩種屬性,white-space變成一個 shorthand,而看到這些定義的演進,其實也讓人發現最早命名時其實只有考慮到西方語言的特色。

看到這邊,會發現有一個新的名詞:"line break opportunity",有些地方是稱為 "wrap opportunity",這個名詞其實是出現在 CSS Module Text 文件中的,顧名思義,就是可以換行的位置,而這份文件也是定義換行邏輯的文件,不過這邊其實沒把換行演算法(line breaking algorithm)明確定義下來,而是闡明各種相關的 CSS 屬性和它們會怎樣影響換行的結果,例如換行有分強制(<br>)和非強制(<wbr>),然後不同 CSS 屬性會影響這些換行點的出現與否,至於文本之中,哪些地方可以換行,就是換行演算法的部分了,這部份在 W3C 文件沒有定義死,所以是允許瀏覽器自己決定的,不過有提供一些參考文件,像是 Unicode 的附件 14:"Unicode Line Breaking Algorithm",或是稱為 UAX14,這份文件要搭配 Unicode Database(UCD) 的Line_Break Property 資料,文件中的第五章有仔細的說明各種不同的 line breaking class,然後資料庫則是定義了所有 Unicode 字元的 Line_Break property,接著的第六章就是最重要的換行演算法了,這邊列出了 31 條規則,基本上是反向列舉,說哪些地方不能換行,例如 WJ(word joiner)前後都不能換行,數字中間的符號前後也不能換行之類的,不過不確定哪些瀏覽器是實作 UAX14 的,Chrome 似乎有用到 UCD,Firefox 則是以 JIS X 4051 為基礎做的換行演算法,其實 JIS X 4051 是我所知道,二戰後世界,最早的正式的文字編排的標準,查到的紀錄是1989 有一版,而 UAX14 第一個非草稿的版本則是要到 1999 年才出來。

回到現在主流瀏覽器不支援<wbr>放在white-space: nowrap裡面無法換行的問題,其實要回到這個 HTML 標籤在繪製時,是不是有 magic 的,什麼是 magic 呢?簡單說就是,你能不能用 CSS 來定義該標籤的樣子,以及你能不能用 CSS 來改掉這些預設的樣式,而<br><wbr>正好是有 magic 的標籤,可以想想看,要怎樣用 CSS 讓文字內產生一個換行點,可能有人會說剛剛上面才看到的,不過,其實那幾個display-outside的值只存在於以前的草稿中,CSS-WG 決議不為了這個問題新增多的 display 狀態,所以問題就回到 HTML-WG 這邊了,CSS-WG 的 fantasai 其實有給了一組 default style:

br { all: unset !important; display: contents !important; content: "\a" !important; white-space: pre !important; }
wbr { all: unset !important; display: contents !important; content: "\200B" !important; }

我把!important拿掉整理一下:

br {
  all: unset;
  display: contents;
  content: "\a";
  white-space: pre;
}
wbr {
  all: unset;
  display: contents;
  content: "\200B";
}

其中,\a就是換行字元\200B則是ZWSP,zero width space,因為是 zero width,所以看不到,然後又因為是 space,所以可以用來把字切開,也就表示可以在該處斷行。不過這個版本有些問題,因為有很多瀏覽器還不支援在::before, ::after以外的物件上套用content屬性,所以 fantasai 又提供一版用::before的版本:

br, wbr { all: unset !important; display: contents !important; white-space: pre !important; }
br::before { all: unset !important; content: "\a" !important; }
wbr::before { all: unset !important; content: "\200B" !important; }

但是,實際上直接拿這組定義來用,還是一樣有問題,就是有些瀏覽器已經讓<wbr>有 magic 了,結果wbr::before是沒有用的,目前HTML 標準的修改也就還卡在這邊(issue 則是另外一個),HTML-WG 的 Domenic 開了這個 PR 要讓<br><wbr>就用 magic 來實現效果,不過這討論已經停很久了,所以最後會是怎樣的方案還不知道。

總之目前的結論就是,現在如果想要讓<wbr>照其定義的一樣,可以在<nobr>或是white-space: nowrap內產生換行,是辦不到的,不過可以用其他的標籤來辦到,像是:

.wbr::before {
  display: inline;
  content: "\00200B";
  white-space: normal;
}

配上

<nobr>ChatGPT: Optimizing<span class="wbr">Language Models<span class="wbr">for Dialogue</nobr>

這個寫法也有出現在一份由 Leif Halvard Silli 在提交bug給 WebKit 所做的test 內

到這邊,大概已經把自己控制換行位置的部分講的差不多了,不過其實,還有一個方法可以處理一開始所提到的換行結果不理想的問題,就是在 CSS Text Module Level 4 中有一個新的屬性叫做text-wrap,其中有一個屬性值是balance,合起來就是

text-wrap: balance;

這樣設定,預期的結果就是會換行換在每一行的寬度最接近的位置,不過當然還沒有瀏覽器支援,連 caniuse 都還查不到text-wrap屬性,只是還是有 JS 的解決方案:

最後的最後補上一些參考資料,一篇是古老的 IE 時代的東西,一篇則是現在的相關 CSS 屬性,一篇則是 balance wrap 的介紹:


日本郵便番号

北海道

因為工作的關係,要做日本郵遞區號和地址間的自動補完,還因此開始維護japan-postal-code-oasis這個套件,它的前身其實是另外一套叫japan-postal-code的,但是它很久沒更新了,所以我就簡單修改一些設計,在研究套件設計的同時就有機會比較深入了解日本的郵遞區號(郵便番号)系統,發現不少有趣的細節,這篇文章特別來紀錄一下。

首先,第一點就是,日本郵遞區號的資料是有整理好並開放下載的,而且下載路徑、檔案格式都是固定的沒什麼變動過,也因此可以找到不少使用這些資料的程式語言套件(包含下載腳本),japan-postal-code就是透過這份資料來實作從郵遞區號抓地址資訊的功能,雖然不是很想和台灣比,但是這點台灣真的是輸很多,台灣其實到 3+2 郵遞區號都還可以在郵局網站下載的到,但是那個下載連結不是固定的,然後也看不出來是不是固定更新,所以你不會知道你下載的資料是不是最新的,至於最新的 3+3 郵遞區號,就沒有開放大眾下載,取而代之的是提供 Web Service、Windows 應用程式,如果真的要下載資料檔,要特別申請(參照:本公司3+3郵遞區號系統使用規範說明事項),另外就是台灣是一季更新一次,日本則是一個月更新一次。

其實我會注意到日本每個月都更新郵遞區號,是從另外一件事情發現的,在繼續之前,先來介紹一下日本郵遞區號內不同數字的意義,這在日本郵局網站有很詳細的說明,前三碼稱為郵便區番号,後四碼是町域番号,其中的前兩碼是大 block 番号,第三碼則是小 block 番号,基本上都是這樣規劃,理論上不會需要到每個月更新,直到有一天我發現到六本木森大樓,每一層樓都有自己的郵遞區號,例如用 Google Map 搜尋Google Japan (Roppongi Hills),然後查看地址就會發現他的郵遞區號是 106-6126,其中的後兩碼就是 26 樓的意思,然後搜尋Apple Japan合同会社,就會發現郵遞區號是 106-6140,就是 40 樓的意思。後來我才知道,這是屬於 個別番号 之中的超高層大樓番号,除了大樓之外,還有其他種個別番号,像是大型機構(醫院、電視台、公家單位等)也可以申請,而這種個別番号也就是資料內最常會變動的地方,所以他們才有這麼頻繁的資料更新。

其實我還有在一些第三方資料,看到說前三碼的郵便區番号,還可以再更細分成兩組:前兩碼是地域番号,第三碼則是地域調整番号,町域番号的最後一碼則是町域調整番号,另外就是第一版的日本郵遞區號是三碼為主,然後有部分地區有 3+2 碼的設計,網路上還找的到當時(1968、昭和43年)第一版的郵遞區號的清單

再來印在信封上的,手寫郵遞區號的格子,其實在日本郵局網站上也有仔細的定義它的尺寸,甚至連手寫的字體也有提供範例(基本上就是要你不要寫的太潦草),台灣郵局網站我找到的最接近的就是只有信封書寫範例了,其實日本的規範這麼仔細,有一個原因是為了自動化作業。

郵便番号枠

這個自動化作業,其實遠超我的預期,不只是根據郵遞區號分類而已,日本郵局其實定義了一套地址的數位化編碼系統,轉換邏輯也不困難,也就是說,日本國內的任意地址,都可以用一串數字(看情況加上英文字母)來表示,日本地址可以簡單的轉為英數編碼,有一個很大的原因是他們的地址是從大範圍(ex: X丁目X番X号)到小範圍這樣,而不像是其他地方用路加上號碼(ex: XX路X號)的方式,所以其實編碼機制也很簡單,大部分的地址,就是郵遞區號加上後面的三碼,然後再加上可能樓層、房號就可以組成,例如東京鳩居堂 銀座本店,地址是:

〒104-0061 東京都中央区銀座5丁目7−4

轉成編碼就是:

10400615-7-4

前七碼固定式郵便番号,所以不用處理分隔符號的問題。

另外日本地址有個天字第一號(我取名的),大家可以猜猜看是哪個地方的地址,沒錯,就是皇居,郵遞區號是 100-0001,地址是 1 丁目 1 番(沒有号),編碼就是:

10000011-1

而且這套編碼系統還有一組專用的barcode 系統,可以用掃描的方式輸入,非常方便,barcode 的定義也蠻有趣的,不是用粗細,而是用長短和位置來表達不同意義,一條 bar 有四種(4-state)可能的變化(1-4),所以是 2bit,然後一個字元是三條 bar,所以是 6bit,可以有單一字元或是雙字元,理論上單字元有 64 種組合,雙字元是 12bit 共 4096 種組合,不過其實雙字元的第一個字元固定是控制碼,所以實際上變化沒這麼多,目前也只有定義英數、hyphen 和一些控制字元而已,日本郵局內部似乎還有其他用途,而不是只有標記收件人地址而已,不過細節沒有網站上沒有公開,這部份有分為局內 barcode 和 ID barcode,而自己用這組 barcode 系統標注地址的部分,則是稱為 custom barcode。

郵便番号 カスタマバーコード

至於要如何產生住址的 custom barcode 呢,日本郵局網站是有蠻詳細的說明,包括怎樣拆解地址、怎樣編碼、檢查碼怎樣算等等都有說明。而也還有提供一個 custom barcode 產生程式可以下載,抓下來解開後可以發現是網頁應用程式,上一個世代的,編碼也不是 UTF-8 的,現在還可以正常運作,其實還蠻厲害。

PS. 後來發現其實有蠻多國家的郵務系統是使用類似的 4-state barcode 的。

接下來算是不相關的資訊了,其實我在查資料時,有想研究看看郵便番號有沒有和其他標準相似或是共通的地方,首先是有注意到 ISO 3166-2 這個標準,是每個國家自己有一個子集,定義了各自國家的行政區,例如日本的是 ISO 3166-2:JP,裡面就是用 01-47 從北到南把日本的都道府縣編碼了一遍,而且直接對應到 JIS X 0401,最初是在好奇 JIS 的編碼和郵便番号前兩碼的地域番号有沒有相通,結果當然是沒有。其實我還蠻羨慕日本有 JIS 標準統整各種規範的單位,台灣也是有相關的編碼,只是就是像是ISO 3166-2:TW Wikipedia 條目裡面列出的,就是各單位各自為政這樣。

另外一套標準(?)則是日本導航系統幾乎都會有支援的MAPCODE,這個編碼系統就不是官方標準了,而是民間企業日本電装(前身是 Toyota 自動車的一個部門)開發的定位系統,是針對日本境內的,用類似 quadtree 的方式,把地圖分割成一小塊一小塊,然後每一塊下去編碼,不過和 quadtree 不一樣的是,不是統一分成四塊,而是分成三個層級,第一級是 Zone 全日本被分為 1203 個 Zone,然後每個 Zone 再細分成 30x30 個 Block,每個 Block 一樣再細分成 30x30 個 Unit,如果還要更細,還可以再細分成 Core 和 S Core,大約可以到 3 公尺的精細度,最後就是當然,這套系統的大分區也和郵遞區號完全不相通。


Archlinux 修復紀錄

Universal Studio Singapore

之前我在推特上有說過我不小心把我放 blog 的主機搞壞,當時就是用 pacman 更新過後,出現一些錯誤,我快速的重跑pacman -Syu然後就開始一直出現錯誤了,當時想說是因為我太久沒更新,然後有相依性錯誤造成系統幾乎爛掉,一度要放棄,不過因為網站相關的 instance 都還跑著,所以我就想說暫時放著,等有空把資料弄出來再重建系統,然後十一月中去了一趟新加坡,這趟行程要邊顧小孩其實很累,然後就在回來當天晚上就收到 Linode 的緊急維護,已經把我的 Linode 主機重開了,網站當然也死了,真的是晴天霹靂,不過實在太累了我也只能先放著不管。

過了幾天終於比較有力氣來看看看問題,我當時的狀況是,無法使用 pacman,然後更進一步發現是 curl 就死掉,curl 死掉會造成很多東西一起掛掉,像是 git、wget 也都掛了,結果我能使用的工具和手段就變的很少,總之先來看看錯誤訊息吧:

/usr/lib/libcurl.so.4: undefined symbol: BrotliDecoderCreateInstance

由此可知基本上問題就是動態連結 Brotli 的 library 時出錯,我還記得我當初裝機器時,Archlinux 還沒有正式 Brotli 的套件,所以我還自己編譯了一版給 nginx 用,而我的 nginx 也是自己編譯的,沒想到不知不覺 Archlinux 已經有正式的 Brotli 套件,而且 curl 還相依於它。

接著我就開始各種嘗試,想辦法重新裝 Brotli 套件,curl 雖然不能動,但是我還可以用 scp 傳檔案上去,不過就算傳上去 pacman 也還是完全無法跑起來,即使我只是想要他安裝本地的檔案,而不是要連網路,然後我也去看了/use/local/裡面 brotli 套件的 header 檔案,查看內容,發現真的沒有BrotliDecoderCreateInstance,不過這個 symbol 在 Brotli 的 repo 內是有的,而且已經存在了有四年之久,所以顯然,我系統內安裝的版本很有問題,雖然確定問題在哪,但是還是一直沒有解決方法,重裝套件需要 pacman,但是 pacman 需要修好 brotli 才能動,陷入死結當中,更糟的是,我在網路上搜尋就是找不到有一樣問題的人。

然後我就開始研究 pacman 掛掉要怎麼辦,找了許久終於找到有一個pacman-static的工具,是預先編譯好,並且是靜態連結的 pacman 執行檔,抓下來後發現真的可以用,真的是感動的痛哭流涕,然後我立馬執行pacman -Syu,一切執行順利,感動QQ,然後我執行了curl想確認有沒有修好,結果我再次看到了那個一樣的,熟悉的錯誤訊息...

這時我百思不得其解,我用 pacman 看安裝的套件版本確實是新的,我去解開套件來看也是新的,但是我去系統的/usr/local/下看裡面的檔案卻是舊的,重新裝了很多次也都是一樣狀況,就這樣鬼打牆很久之後,我突然察覺,/usr/local/下的東西,其實是我們手動編譯安裝的,也是路徑中優先權較高的,然後我在前面有提過,我很久以前有手動編譯安裝 Brotli 套件,終於,一切真相大白,我手動裝的時間點是五年前,所以該版本沒有BrotliDecoderCreateInstance,然後 Archlinux 用的是四年前版本,所以系統中其他需要 Brotli 的東西都會因此而掛掉,解決方法就是把手動裝的全部砍光光就好了。

不過砍掉我手動編譯的 Brotli,也同時造成我的 nginx 再起不能,因為在設定檔內它是需要我手動編譯安裝的那那個套件,解決方法是很簡單,就把需要的 module 路徑改到 pacman 安裝的套件那邊,然後我的 nginx 就可以起來了,不過我的 blog 還是死的,非 blog 的部分倒是活著,我一開始想說是 php-fpm 的問題,看錯誤訊息發現有 permission 問題,就去改 socket file permisson 成 666,然後網站還是起不來,我研究了很久,想要看看 PHP 的錯誤訊息,但是一直看不到東西,也去看 nginx error log,journalctl 也是看沒錯誤,還以為 php-fpm 是死的,還用了

<? echo phpinfo(); ?>

然後開瀏覽器看到原始碼直接回回來,搞了一陣子想起要改用<?php,改下去結果又發現一切正常,最後才發現,問題是因為我在用 pacman 更新整個系統時,把 PHP 7 升級到 8,然後我的程式碼裡面有個地方寫死大版號不對的話會回錯誤訊息,但是我沒有把錯誤訊息寫到 log 中,結果就造成我一直找不到問題點。

PHP 的問題解決後,我的 blog 就回到線上了,不過其實,這時候我的 mariadb 還是死的,透過 journalctl 看 log

sudo journalctl -xeu mariadb.service

有一行寫著:

Plugin 'InnoDB' registration as a STORAGE ENGINE failed.

一開始想說是安裝失敗,後來往上找發現還有另外一行:

InnoDB: Upgrade after a crash is not supported. The redo log was created with MariaDB 10.4.8

意思就是如果你的 DB crash 後,沒有正常關閉的狀態下,去更新 mariadb,就會有這個錯誤,不過一開始我不以為意,因為我認為我只有 upgrade,但是不知道是何時 crash 過,後來回想,應該是 brotli 爛掉時,mariadb 就跟著起不來了,總之,這個問題的解決方法,就是退回舊的 10.4.8 然後重新啟動 DB,所以就研究了一下 Archlinux 怎樣安裝舊版的套件,基本上 pacman 是不能指定版本的,有兩個方法可以裝舊版,一個是透過系統內的 pacman cache,不過我之前在修理的時候已經清掉了,所以就只能從Arch Linux Package Archive那邊下載特定版本的 package tar 檔案,下來用pacman -U安裝,然後因為有相依性問題,所以要把幾個需要的套件都抓下來,一起安裝:

pacman -U mariadb-10.4.8-2-x86_64.pkg.tar.xz \
  mariadb-clients-10.4.8-2-x86_64.pkg.tar.xz \
  mariadb-libs-10.4.8-2-x86_64.pkg.tar.xz

反正如果啟動失敗,也會有訊息提示你要看 log,結果把 mariadb 三個都裝下去後還真的有問題,說找不到 openssl 1.1 的檔案,所以也去抓下來手動安裝:

pacman -S openssl-1.1

至此,總算是修好了,接下來就是有時間要把我的 blog 系統容器化吧,有太多不是很好安裝的東西了。

PS. 這篇發的出去表示真的修好了。


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 還是比較簡單啦。


➡ 看看其它文章