包包baobaolin.com
date
entry
030
topic
infrastructure
rev
—

CloudFront Function 自己回 301,query string 會在兩個地方掉

CloudFront 不會替函式產生的轉址補上 query string。忘了讀 multiValue,或補斜線時只接路徑,參數就安靜地不見。

第一篇講目錄 key 陷阱時,提過另一條路:用 CloudFront Functions 在 viewer request 階段把 /blog/ 改寫成 /blog/index.html。我後來在幾個站改走這條路,同一支函式順便做 www 與主網域之間的 301,因為同一個 cache behavior 的 viewer request 只能掛一支函式。

轉址是函式自己回的 response,Location 由程式碼組出來,query string 也得自己帶上。我用 curl 打其中一個站(下面稱「只讀 value 的那支」):?tag=a&tag=b 轉完只剩 ?tag=a,/blog?utm_source=line 轉完變成 /blog/,utm 整段不見。這篇要談的是這兩個掉參數的地方。依序回答四件事:event 裡的 query string 長什麼樣、兩處各怎麼掉、怎麼在本機測、修完的函式。

event 裡的 query string

CloudFront Functions 的文件(Event structure 那頁)寫明,request.querystring 是一個物件,每個參數名一個欄位,值放在 value。同名參數出現不只一次時,會收成同一個欄位,多一個 multiValue 陣列放全部的值,第一個值同時出現在 value 和 multiValue 裡。節錄文件範例的部分欄位:

"querystring": {
  "ID": {"value": "42"},
  "NoValue": {"value": ""},
  "querymv": {
    "value": "val1",
    "multiValue": [{"value": "val1"}, {"value": "val2,val3"}]
  }
}

Event structure 那頁沒有直接說值有沒有做過百分比解碼。我用 curl 打線上的函式,?q=a%20b&r=%26x%3D1&s=%E5%8C%85 轉完之後三個值都還是原本的百分比編碼。所以重組 Location 時直接用這些字串,不要再編一次,否則 %20 會變成 %2520。

第一處:只讀 value,同名參數只剩一個

其中一支函式組 query string 的寫法:

var qs = Object.keys(request.querystring).map(function (k) {
  var v = request.querystring[k];
  return v.value ? k + '=' + v.value : k;
}).join('&');

它只讀 value。?tag=a&tag=b 進來,value 是 a,b 只在 multiValue 裡,於是轉完只剩 tag=a。順帶一個小差異:value 是空字串時它輸出 empty 而不是 empty=(另一支對單一空值也一樣)。例如瀏覽器的 URLSearchParams 把兩種寫法都解析成空字串,但字面上不是同一個網址,後端會不會區分要另外確認。

另一個站的函式有處理 multiValue,同一個網址轉完是 ?tag=a&tag=b。兩支在換主機時都要重組 query,一支只讀 value,一支也讀 multiValue;目錄的處理策略也不同,下一節會談。

第二處:補斜線的轉址沒帶 query

同一支函式對最後一段不含 .、也沒有結尾斜線的路徑(例如 /blog)回 301 到 /blog/。這是用「有沒有點號」猜是不是目錄:

return { statusCode: 301, statusDescription: 'Moved Permanently',
         headers: { location: { value: uri + '/' } } };

Location 只有路徑,query 沒接上。www 的那一段有帶 query,所以兩個轉址串起來時,前一跳保留、後一跳丟掉:

$ curl -sSIL 'https://www.<站>/blog?utm_source=line' | grep -iE '^(HTTP|location)'
HTTP/2 301
location: https://<站>/blog?utm_source=line
HTTP/2 301
location: /blog/
HTTP/2 200

兩跳之後 utm 不見。分析工具如果靠這組參數辨識來源,活動歸因就可能受影響,沒有其他來源資訊時甚至可能算成直接流量;實際報表我沒有去對。實際影響取決於對外分享的連結有多少是不帶斜線的版本,這我沒有統計。

另一支函式的選擇不同:/blog 不轉址,直接把 request.uri 改寫成 /blog/index.html 交給後面處理,線上實測回 200。網址列不變,query 不會掉;代價是同一份內容至少有 /blog 和 /blog/ 兩個網址,頁面上的 canonical 只能表達偏好,最後選哪個由搜尋引擎決定。

在本機跑 handler

兩個問題都是邏輯錯誤,不需要上 AWS 就能測。函式檔裡只有一個 handler,沒有 export,用 new Function 包起來就能在 Node 裡呼叫,event 照文件的格式自己組。下面存成 .mjs 用 node 執行,函式檔放在同一個目錄,host 要換成那支函式認得的網域:

import { readFileSync } from "node:fs";
const load = (f) => new Function(readFileSync(f, "utf8") + "\nreturn handler;")();
const ev = (host, uri, qs = {}) => ({ version: "1.0",
  context: { eventType: "viewer-request" }, viewer: { ip: "203.0.113.1" },
  request: { method: "GET", uri, querystring: qs,
             headers: { host: { value: host } }, cookies: {} } });

const h = load("www-redirect.js");
console.log(h(ev("www.example.com", "/blog/",
  { tag: { value: "a", multiValue: [{ value: "a" }, { value: "b" }] } })));

四個案例在兩支函式上的結果(「轉 www」那支的主網域是 www,所以 host 對調):

案例只讀 value 的那支讀 multiValue 的那支
換主機,?tag=a&tag=b?tag=a?tag=a&tag=b
主網域 /blog?utm_source=line301 → /blog/改寫成 /blog/index.html
換主機 /blog?utm_source=line301,保留 utm(下一跳才掉)301,保留 utm
空值 ?empty=?empty?empty

本機的結果和線上 curl 一致。這個做法有兩個限制要講清楚。第一,Node 不是 CloudFront Functions 的執行環境(這兩支分別設定 cloudfront-js-1.0 和 2.0),語法支援不同,本機過了不代表上傳後能跑;發布前仍要用 aws cloudfront test-function 在真的環境跑一次(這篇只做本機測試與線上 HTTP 觀察,沒有呼叫 AWS 的管理 API)。第二,參數順序:本機這組參數名照我建立的順序列出,線上實測 ?q=…&r=…&s=… 轉完變成 ?s=…&r=…&q=…,順序反過來。文件沒有保證順序,依賴參數順序的後端要另外處理。

修完的函式

兩個改動:把組 query 的邏輯抽成一個函式,讀 multiValue,空值一律輸出成 key=;換主機和補斜線合併成一跳,直接轉到最終網址。

function qs(request) {
  var parts = [];
  Object.keys(request.querystring).forEach(function (k) {
    var e = request.querystring[k];
    (e.multiValue ? e.multiValue : [e]).forEach(function (v) {
      parts.push(v.value === '' ? k + '=' : k + '=' + v.value);
    });
  });
  return parts.length ? '?' + parts.join('&') : '';
}
function handler(event) {
  var request = event.request;
  var host = request.headers.host && request.headers.host.value;
  var uri = request.uri;
  var needsSlash = !uri.endsWith('/') &&
                   uri.lastIndexOf('.') < uri.lastIndexOf('/') + 1;
  if (host === 'www.example.com' || needsSlash) {
    return { statusCode: 301, statusDescription: 'Moved Permanently',
             headers: { location: { value:
               'https://example.com' + uri + (needsSlash ? '/' : '') + qs(request) } } };
  }
  if (uri.endsWith('/')) request.uri = uri + 'index.html';
  return request;
}

用同一個本機腳本跑:?tag=a&tag=b 兩個都在,/blog?utm_source=line 不論從哪個主機進來,都一跳到 https://example.com/blog/?utm_source=line。注意它的行為比原本寬:任何 host 進來、只要需要補斜線,都轉到主網域,包括直接打 CloudFront 預設網域的請求。我認為這是對的,但要先確認那個站沒有別的網域掛在同一個 distribution 上,也沒有不能被當成目錄的路徑:用點號猜目錄,/.well-known/acme-challenge/ 底下的無副檔名檔案、API 路徑都會被補上斜線,要另外排除。

這支掛在 viewer request 上,改錯的影響是走這個 cache behavior 的所有請求;distribution 只有預設 behavior 時,就是全站。本機腳本適合當發布前的第一關,因為它不需要憑證、幾毫秒跑完;第二關是 test-function,兩關都過才 publish-function,發布後再用 curl 走一次轉址鏈。這個修正我還沒有發布到線上。

如果只記得一件事

CloudFront Function 回 301 時,整個網址是你自己組的,CloudFront 不會替你補 query string。開頭那兩個掉參數的網址,在本機十幾行的腳本裡就能重現,前提是 event 要照文件的格式組,包含同名參數的 multiValue。

修訂紀錄

  1. 首次發布