将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录
为了进一步缩短自己的博客的 TTFB,我决定将自己的博客部署到 Cloudflare Workers Site。没想到我会把几天时间花费在折腾这个上。
缘由
哪个男孩不想拥有一个速度特别快的博客 非常关心博客速度的苏卡卡时不时就用 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,改善了向后兼容性。
kv4cf
如果你也正在用 Cloudflare Workers Site、且也遇到了中文字符路径的问题,在等待他们合并我的两个 Pull Request(或者找到更好的解决方案)、发布新版本之前,你可以先为 Cloudflare Workers Site 安装我修改过的 kv4cf
。 kv4cf
的 API 和 @cloudflare/kv-asset-handler
几乎完全一致,但是支持路径包含中文等 Non-ASCII 字符、还提供了正确的自定义错误处理方法(而且使用 JavaScript 而不是 TypeScript 编写,诶嘿嘿)。
kv4cf
安装方法如下:
$ cd workers-site # 前往 workers-site 目录
$ npm uninstall npm uninstall @cloudflare/kv-asset-handler # 卸载 Cloudflare 的 kv-asset-handler
$ npm i kv4cf # 安装 kv4cf
接着,修改 workers-site/index.js
引入 kv4cf
:
// 将这一行注释掉
// import { getAssetFromKV } from '@cloudflare/kv-asset-handler'
// 然后加上这一行
const { getAssetFromKV } = require('kv4cf');
别问,问就是我喜欢 Node.js 的 CommonJS。反正 Cloudflare Workers 用 webpack 构建是同时支持 CommonJS 和 ESModule 的。
kv4cf
开源在 GitHub 上,欢迎大家围观w。