将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录

为了进一步缩短自己的博客的 TTFB,我决定将自己的博客部署到 Cloudflare Workers Site。没想到我会把几天时间花费在折腾这个上。
本文完成于 2020 年 6 月 7 日,Cloudflare Workers Site 已经被弃用并被 Cloudflare Pages 取代,本文部分内容可能已经过时,但是 Cloudflare 那一事无成的技术支持和工单 是恒久不变的。
缘由
哪个男孩不想拥有一个速度特别快的博客 非常关心博客速度的我时不时就用 Google 的 Pagespeed 跑一次分。终于 Google Pagespeed 给我报了一项问题 —— 「网站 TTFB 过高(0.15s)」。
过去,我的博客部署在 Serverless 平台上,套了一层 Cloudflare,同时启用了 Cloudflare Argo。Cloudflare 控制台的数据是没有经过 Argo 优化的回源用时为 385ms;即使通过 Argo 回源平均用时也有 165ms。而就算这样,Google Pagespeed 也觉得不够快。
终于,去年九月份 Cloudflare Workers 放出了一个新产品「Cloudflare Workers Site」,与所有的静态网站生成器(Hexo、Hugo、Jekyll、Gatsby 等)兼容。Cloudflare Workers Site 是 Cloudflare Workers 提供了 KV Storage(Workers KV)以后开发出的一个功能,将静态文件存储在 KV Storage 中,Cloudflare Workers 从 KV 存储中获取文件并以 HTTP 响应的形式返回,实现静态网站托管。
这听起来几乎太完美了,一个类似于 GitHub Pages 的静态网站托管服务,直接可以部署在 Cloudflare 的 200+ 数据中心上,免去了回源的几百毫秒延时。
从 CLI 进行首次部署
Cloudflare 的文档提供了相对详细的教程 介绍如何将现有的网站迁移到 Cloudflare Workers Site。按照教程一步一步操作即可。
安装 Wrangler CLI
既然已经有了 Node.js 环境,通过 NPM 安装 Wrangler CLI 自然是最合适的选择。
$ npm i @cloudflare/wrangler -g
# yarn global add @cloudflare/wrangler # 我反正喜欢用 yarn 创建 API Token
使用 Wrangler CLI 前需要 先申请一个 API Token。Cloudflare 提供了包括「Edit Cloudflare Workers」在内的一系列 Token 模板,直接选择 Workers 那个即可。不过需要注意的是,在下一步设置 Token 权限时将下图两个权限去掉:部署 Cloudflare Workers Site 不需要用到这两个权限。
其余的配置同理,遵循「权限最小化」原则配置 API Token 的账户限制、域名限制还有有效期。
初始化项目
在现有的项目(如 Hexo 站点目录)下执行:
$ wrangler init --site my-static-site # 其中 my-static-site 是目录名称 Wrangler CLI 会使用 Cloudflare Workers Site 的模板在项目里新生成一个 workers-site 目录和一个 wrangler.toml 文件(为什么不用 YAML 呢,TOML 一股 ini 味)。接着手动配置 wrangler.toml:
[site]
bucket = "./public" # 生成的 dist 路径,对于 Hexo 和 Hugo 来说就是 public
entry-point = "workers-site" # Cloudflare Workers 相关代码存放处,默认就是 workers-site 目录 如果要将 Cloudflare Workers Site 部署在自己的域名下,还需要一些额外的配置:
account_id = "" # Cloudflare Account ID,去 Cloudflare 的控制面板找找
workers_dev = false # 是否启用 workers.dev 子域名
route = "blog.skk.moe/*" # Workers 所载的 Route
zone_id = "" # 域名在 Cloudflare 的 Zone ID 接着,运行 wrangler config,按照提示输入前一步获取到的 API Token 即可。
预览和发布
运行下述命令即可对 Cloudflare Workers Site 进行预览:
$ wrangler preview --watch # 将 bucket 中的文件上传到 Workers KV 中,浏览器会自动打开一个窗口进行预览 运行下述命令即可将网站发布到 Cloudflare Workers Site:
$ wrangler publish # 将 Workers Site 发布到生产环境 使用 GitHub Action 持续集成 Hexo 并部署到 Cloudflare Workers Site
使用 Cloudflare Workers Site 部署 Hexo 听起来很简单,一样的 hexo g,然后把 hexo d 换成 wrangler publish。但是我一直以来都在使用 Travis CI 持续集成和部署 Hexo,怎么能因为换了 Cloudflare Workers Site 就放弃了 CI/CD?趁着这个机会,还可以从 Travis CI 迁移到 GitHub Action 上。
编写 GitHub Action 配置文件
在目录下新建目录 .github/workflows,并在该目录下新建任意名称的 YAML 文件,该文件将作为 GitHub Actions 的配置文件。
作为 Hexo 的核心团队成员之一,我强烈建议不要使用任何 Hexo 的 GitHub Action。把 Hexo 当成一个普通的依赖 Node.js 的构建程序、遵照这个思路编写 GitHub Action 的配置文件即可。
name: My Hexo Blog
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12.x
steps:
- name: Checkout
uses: actions/checkout@v2
with:
# 令 GitHub 在 git clone 和 git checkout 后「忘记」使用的 credentials。
# 如果之后需要以另外的身份(如你的 GitHub Bot)执行 git push 操作时(如部署到 GitHub Pages),必须设置为 false。
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
# 缓存 node_modules,缓存机制参见 GitHub 文档:https://help.github.com/en/actions/configuring-and-managing-workflows/caching-dependencies-to-speed-up-workflows
- name: Cache node_modules
uses: actions/cache@v1 # 使用 GitHub 官方的缓存 Action。
env:
cache-name: hexo-node-modules
with:
path: node_modules
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} # 使用 package-lock.json 的 Hash 作为缓存的 key。也可以使用 package.json 代替
# Wrangler 在构建时会在 workers-site 目录下执行 npm i,因此也要缓存这里的 node_modules
- name: Cache workers-site/node_modules
uses: actions/cache@v1
env:
cache-name: workers-site-node-modules
with:
path: workers-site/node_modules
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('workers-site/package-lock.json') }}
- run: npm i # 执行 Hexo 的依赖安装
# 完成 npm i 后,hexo 已经被链接到 node_modules 下的 bin 目录、并被注册在 Node.js 的 $PATH 中
# Hexo 博客的 package.json 中默认注册了这些 script:clean/build/deploy/server
# 因此,在目录下执行 npm run build 等同于执行 hexo g,但是不需要全局安装 hexo-cli
- run: npm run build 先将配置文件推到 GitHub 上,如果 GitHub Action 自动触发开始构建,且没有错误发生,再添加部署到 Cloudflare Workes Site 的相关配置。
通过 GitHub Action 将构建结果发布至 Cloudflare Workers Site
Cloudflare 推出了 Wrangler CLI 的 GitHub Action,通过引入 wrangler-action 可以直接执行 wrangler publish。先在 GitHub 仓库的设置页面添加 Secrets 环境变量,内容为之前生成的 Cloudflare 的 API Token(当然,如果遵循「权限最小化」和分割原则,还是为 GitHub Action 专门再生成一个 API Token)。
然后,在之前的 GitHub Action 配置文件的结尾补充以下内容:
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@1.1.0
with:
apiToken: ${{ secrets.CF_WORKERS_TOKEN }} # 前一步设置的 Secrets 的名称
# Wrangler Action 也支持使用传统的 Global API Token + Email 的鉴权方式,但不推荐 自定义 Cloudflare Workers Site 的行为
相比之前使用的 Serverless 平台可以通过配置文件充当 Router、以及为不同路径添加响应头等,Cloudflare Workers 都需要编写代码才能实现。
虽然 Cloudflare Workers 提供了一个 Router 的样例,其 API 和 Express 非常接近,然而我不需要通过 Router 将请求引导至不同的后端 Endpoint,而且 Cloudflare 给出的 Router 代码 过于的简陋和 …… 算了,你自己点进去看看就知道了。
这给了我一个 Cloudflare Workers Team 不太会写 JavaScript 的感觉。没有想到的是,这个感觉接下来会被不断地印证。
好在 Cloudflare Workers 正如其名是个 Workers,worker-site 目录下的 index.js 不到 1000 行的代码,dirty hack 不是什么问题。
你可以在 Cloudflare Workers Site 的模板仓库 查看完整的代码,我在这里只给出一小部分样例
import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'
addEventListener('fetch', event => {
try {
event.respondWith(handleEvent(event))
} catch (e) { /* Some error handler stuff */ }
})
async function handleEvent(event) {
try {
const page = await getAssetFromKV(event, {})
// allow headers to be altered
const response = new Response(page.body, page)
response.headers.set('X-XSS-Protection', '1; mode=block')
return response
} catch (e) {
// if an error is thrown try to serve the asset at 404.html
try {
let notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: req => new Request(`${new URL(req.url).origin}/404.html`, req),
})
return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 })
} catch (e) {}
}
} 上面这份代码再一次让我觉得 Cloudflare Workers Team 的 JavaScript 水平堪忧,例如他们在样例代码中声明一个没有再次赋值的变量
notFoundResponse时用的是let而不是const(你不能说他们不用const,因为第 12 行刚刚用了一次)。还有,读过@cloudflare/kv-asset-handler的文档和其 Types 定义的人都会知道getAssetFromKV返回的就是Promise<Response>对象,没必要new Response()。
上述样例代码介绍了在 Cloudflare Workers Site 中设置响应头的方法,以及如何将 404.html 文件作为 404 响应的输出。所以,是时候动手 hack 一下了,先在 handleEvent 中添加 URL 解析:
async function handleEvent(event) {
const { origin, pathname: path, search } = new URL(event.request.url);
// Other stuff goes here
} 接下来,将以 index.html 结尾的请求 301 跳转到不包含 index.html 的 URL:
if (path.endsWith('/index.html')) {
return new Response(null, {
status: 301,
headers: {
'Location': `${origin}${path.substring(0, path.length - 10)}${search}`,
'Cache-Control': 'max-age=3600'
}
});
} 由于 Cloudflare 的 kv-asset-handler 包含了处理 Cloudflare CDN 缓存的逻辑,因此设置在 CDN 上缓存不能通过手动添加 Cache-Control 响应头实现(Cloudflare 的 Workers 在 Cache Layer 外面,通过 Workers 设置的响应头 Cache Layer 是读不到的),而是需要作为 getAssetFromKV 的参数传进去。与此同时,顺便去掉不必要的 new Response:
const response = await getAssetFromKV(event, {
cacheControl: {
edgeTtl: 60 * 60,
browserTtl: 5 * 60
}
}); 虽然为 getAssetFromKV 传入参数后会自动添加响应的 Cache-Control 响应头,但是 CSS 等静态资源文件、以及 RSS 等应该比 HTML 缓存更久一些,所以可以根据路径在前面添加专门的处理逻辑:
if (path === '/atom.xml') {
return getAssetFromKV(event, {
cacheControl: {
edgeTtl: 60 * 60;
browserTtl: 2 * 60 * 60;
}
});
}
if (path.startsWith('/css/')) } {
const response = await getAssetFromKV(event, {
cacheControl: {
edgeTtl: 365 * 24 * 60 * 60;
browserTtl: 365 * 24 * 60 * 60;
}
});
// getAssetFromKV 不会添加 immutable,所以需要手动覆盖掉 Cache-Control
response.headers.set('cache-control', `public, max-age=${365 * 24 * 60 * 60}, immutable`);
return response;
}
const response = await getAssetFromKV(event, {
cacheControl: {
edgeTtl: 60 * 60,
browserTtl: 5 * 60
}
});
response.headers.set('X-XSS-Protection', '1; mode=block');
return response; 让 Cloudflare Workers Site 处理中文路径
搞定 Cache-Control 和响应头、嘲笑并议论 小飞机 Cloudflare 的代码,然后把自己的博客部署到 Cloudflare Workers Site,信心满满感觉博客快了几百毫秒,应该去买杯奶茶犒劳一下自己。
结果,不到半小时监控就开始报警了,博客 4xx 状态码异常增多的警告开始轰炸我的邮箱和 Telegram。
这 Cloudflare Workers Site 一事无成,像极了我的人生。
还好之前在用的 Serverless 并没有停掉,删掉 Cloudflare Workers 的 Route、无缝回滚到之前的部署方式。4xx 状态码的数量是下去了,但是问题仍然需要解决。好在我已经是 Cloudflare Enterprise Customer 了,可以直接查看 Cloudflare 的原始 Log。Pull 下日志然后 grep 一下,发现 URL 中包含中文字符的请求都 404 了。
作为 Cloudflare Enterprise Customer,遇到这种问题当然是马上给 Cloudflare 发个工单,然后让他们工程师来解决啦。
「Expected Behavior」、「Known Issue」、「No ETA」、「Migrate Away」、「Thanks for your patience」。
这 Cloudflare 技术支持一事无成,像极了他们的 Cloudflare Workers Site。
Cloudflare 的 kv-asset-handler 是开源的,核心代码甚至不到 200 行,我不妨来看看。
// https://github.com/cloudflare/kv-asset-handler/blob/0be199020400771fda976ffa2975df51b5f9b910/src/index.ts#L100-L104
const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s
//set to the raw file if exists, else the approriate HTML file
const requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request)
const parsedUrl = new URL(requestKey.url)
const pathname = parsedUrl.pathname kv-asset-handler 通过 URL API 解析得到 URL 中的 pathname,并去除了开头的 /。根据 WHATWG URL 规范,new URL() 得到的 pathname 是经过 encode 过的:
const url = new URL('https://example.com/你好/');
url.href;
// "https://example.com/%E4%BD%A0%E5%A5%BD/"
url.pathname;
// "/%E4%BD%A0%E5%A5%BD/" 与此同时,Wrangler CLI 在 Publish 时存到 Workers KV 里的是原始的路径和文件名:
所以 kv-asset-handler 通过 ASSET_MANIFEST[rawPathKey] 拿到的一定是 undefined,加上没有传入 options.mapRequestToAsset(request),getAssetFromKV 自然会 throw 出一个 Error,最后被外部 catch 到、以 404 的形式展示出来。
值得一提的是,我在查阅他们的 README 时、从样例代码中发现了很荒唐的错误 —— 他们通过 extends 的方式自定义错误类型:
// https://github.com/cloudflare/kv-asset-handler/blob/0be199020400771fda976ffa2975df51b5f9b910/src/types.ts
export class KVError extends Error { /* ... */ }
export class MethodNotAllowedError extends KVError { /* ... */ }
export class NotFoundError extends KVError { /* ... */ }
export class InternalError extends KVError { /* ... */ } —— 但是他们的 README 中给的错误类型判断 样例代码竟然是:
Excuse me?先不说 resp 全程都没有被声明,也不说 NotFoundError 和 MethodNotAllowedError 一样没有被引入或声明,用 typeof 试图得到 Class 的类名是什么操作?
这已经不是是否擅长的问题了,Cloudflare Workers Team 真的学过怎么写 JavaScript 么?
然而问题还是要修复的。所以我先后开了两个 Pull Request 解决了路径中包含 Non-ASCII 字符的问题,第一个 Pull Request 简单粗暴地引入了 decodeURIComponents 解决 Non-ASCII 字符在 new URL() 时会被 encode 的问题:
-const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s
+const rawPathKey = decodeURIComponent(new URL(request.url).pathname.replace(/^\/+/, '')) // strip any preceding /'s
//set to the raw file if exists, else the approriate HTML file
const requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request)
const parsedUrl = new URL(requestKey.url)
-const pathname = parsedUrl.pathname
+const pathname = decodeURIComponent(parsedUrl.pathname) // decode percentage encoded path 由于 Cloudflare Workers Team 担心这会引入 Breaking Changes(在 Workers KV 中以 encode 过的字符串作为 key 的用户会有 Unexpected Behavior),我又开了 第二个 Pull Request,改善了向后兼容性。